Merge pull request #440 from Mbucari/master

Configuration Change Tracking and Bookk Records
This commit is contained in:
rmcrackan 2023-01-09 11:41:34 -05:00 committed by GitHub
commit 5c450a01a4
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
57 changed files with 2832 additions and 707 deletions

View File

@ -43,7 +43,21 @@ namespace AaxDecrypter
return false;
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Cleanup");

View File

@ -49,6 +49,19 @@ namespace AaxDecrypter
}
//Step 4
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 5
Serilog.Log.Information("Begin Step 4: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 4: Cleanup");

View File

@ -48,6 +48,7 @@ namespace AaxDecrypter
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
@ -132,14 +133,27 @@ namespace AaxDecrypter
return success;
}
protected async Task<bool> Step_DownloadClipsBookmarks()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
if (!File.Exists(jsonDownloadState))
return NewNetworkFilePersister();
NetworkFileStreamPersister nfsp = default;
try
{
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
if (!File.Exists(jsonDownloadState))
return nfsp = NewNetworkFilePersister();
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
@ -149,7 +163,12 @@ namespace AaxDecrypter
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(TempFilePath);
return NewNetworkFilePersister();
return nfsp = NewNetworkFilePersister();
}
finally
{
if (nfsp?.NetworkFileStream is not null)
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
}
}

View File

@ -1,9 +1,12 @@
using AAXClean;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
event EventHandler<long> DownloadSpeedChanged;
FileManager.ReplacementCharacters ReplacementCharacters { get; }
string DownloadUrl { get; }
string UserAgent { get; }
@ -14,6 +17,8 @@ namespace AaxDecrypter
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
@ -21,5 +26,6 @@ namespace AaxDecrypter
bool MatchSourceBitrate { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitleName(MultiConvertFileProperties props);
}
Task<string> SaveClipsAndBookmarks(string fileName);
}
}

View File

@ -41,9 +41,9 @@ namespace AaxDecrypter
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
private static long _globalSpeedLimit = 0;
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public static long GlobalSpeedLimit { get => _globalSpeedLimit; set => _globalSpeedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
#endregion
@ -70,7 +70,7 @@ namespace AaxDecrypter
//Minimum throttle rate. The minimum amount of data that can be throttled
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
private const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
#endregion
@ -202,7 +202,7 @@ namespace AaxDecrypter
bytesReadSinceThrottle += bytesRead;
if (GlobalSpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > GlobalSpeedLimit / THROTTLE_FREQUENCY)
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
if (delayMS > 0)

View File

@ -16,7 +16,7 @@ namespace AaxDecrypter
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
Serilog.Log.Information("Begin downloading unencrypted audiobook.");
//Step 1
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
@ -39,6 +39,19 @@ namespace AaxDecrypter
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Step 3: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 3: Cleanup");
@ -58,7 +71,6 @@ namespace AaxDecrypter
}
}
public override Task CancelAsync()
{
IsCanceled = true;

View File

@ -29,6 +29,9 @@ namespace AppScaffolding
public static class LibationScaffolding
{
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static VarietyType Variety
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
@ -174,6 +177,12 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
if (!config.Exists(nameof(config.DownloadClipsBookmarks)))
config.DownloadClipsBookmarks = false;
if (!config.Exists(nameof(config.ClipsBookmarksFileFormat)))
config.ClipsBookmarksFileFormat = Configuration.ClipBookmarkFormat.CSV;
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
config.AutoDownloadEpisodes = false;
@ -229,7 +238,7 @@ namespace AppScaffolding
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetObject("Serilog", serilogObj);
config.SetNonString(serilogObj, "Serilog");
}
// to restore original: Console.SetOut(origOut);
@ -372,7 +381,7 @@ namespace AppScaffolding
zipUrl
});
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
}
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{

View File

@ -1,6 +1,34 @@
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
{
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
public record UpgradeProperties
{
private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)");
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
public Version LatestRelease { get; }
public string Notes { get; }
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
{
ZipName = zipName;
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
Notes = stripMarkdownLinks(notes);
}
private string stripMarkdownLinks(string body)
{
body = body.Replace(@"\", "");
var matches = linkstripper.Matches(body);
foreach (Match match in matches)
body = body.Replace(match.Groups[0].Value, match.Groups[1].Value);
return body;
}
}
}

View File

@ -0,0 +1,198 @@
using AudibleApi.Common;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices
{
public static class RecordExporter
{
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string>
{
nameof(Type.Name),
nameof(IRecord.Created),
nameof(IRecord.Start) + "_ms",
};
if (records.OfType<IAnnotation>().Any())
{
columns.Add(nameof(IAnnotation.AnnotationId));
columns.Add(nameof(IAnnotation.LastModified));
}
if (records.OfType<IRangeAnnotation>().Any())
{
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
columns.Add(nameof(IRangeAnnotation.Text));
}
if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title));
var col = 0;
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
cell.SetCellValue(c);
cell.CellStyle = detailSubtotalCellStyle;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
// Add data rows
foreach (var record in records)
{
col = 0;
row = sheet.CreateRow(++rowIndex);
row.CreateCell(col++).SetCellValue(record.GetType().Name);
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
if (record is IAnnotation annotation)
{
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++);
lastModifiedCell.CellStyle = dateStyle;
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
if (annotation is IRangeAnnotation rangeAnnotation)
{
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
if (rangeAnnotation is Clip clip)
row.CreateCell(col++).SetCellValue(clip.Title);
}
}
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
}
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
var recordsEx = extendRecords(records);
var recordsObj = new JObject
{
{ "title", libraryBook.Book.Title},
{ "asin", libraryBook.Book.AudibleProductId},
{ "exportTime", DateTime.Now},
{ "records", JArray.FromObject(recordsEx) }
};
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
}
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
//Write headers for the present record type that has the most properties
if (records.OfType<Clip>().Any())
csv.WriteHeader(typeof(ClipEx));
else if (records.OfType<Note>().Any())
csv.WriteHeader(typeof(NoteEx));
else if (records.OfType<Bookmark>().Any())
csv.WriteHeader(typeof(BookmarkEx));
else
csv.WriteHeader(typeof(LastHeardEx));
var recordsEx = extendRecords(records);
csv.NextRecord();
csv.WriteRecords(recordsEx.OfType<ClipEx>());
csv.WriteRecords(recordsEx.OfType<NoteEx>());
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
}
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
=> records
.Select<IRecord, IRecordEx>(
r => r switch
{
Clip c => new ClipEx(nameof(Clip), c),
Note n => new NoteEx(nameof(Note), n),
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
_ => throw new InvalidOperationException(),
});
private interface IRecordEx { string Type { get; } }
private record LastHeardEx : LastHeard, IRecordEx
{
public string Type { get; }
public LastHeardEx(string type, LastHeard original) : base(original)
{
Type = type;
}
}
private record BookmarkEx : Bookmark, IRecordEx
{
public string Type { get; }
public BookmarkEx(string type, Bookmark original) : base(original)
{
Type = type;
}
}
private record NoteEx : Note, IRecordEx
{
public string Type { get; }
public NoteEx(string type, Note original) : base(original)
{
Type = type;
}
}
private record ClipEx : Clip, IRecordEx
{
public string Type { get; }
public ClipEx(string type, Clip original) : base(original)
{
Type = type;
}
}
}
}

View File

@ -104,7 +104,7 @@ namespace FileLiberator
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
@ -133,9 +133,7 @@ namespace FileLiberator
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
return success;
return await abDownloader.RunAsync();
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
@ -168,6 +166,8 @@ namespace FileLiberator
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet,
DownloadClipsBookmarks = config.DownloadClipsBookmarks,
DownloadSpeedBps = config.DownloadSpeedLimit,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
FixupFile = config.AllowLibationFixup

View File

@ -4,43 +4,88 @@ using Dinah.Core;
using DataLayer;
using LibationFileManager;
using FileManager;
using System.Threading.Tasks;
using System;
using System.IO;
using ApplicationServices;
namespace FileLiberator
{
public class DownloadOptions : IDownloadOptions
{
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public bool FixupFile { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public bool Downsample { get; init; }
public bool MatchSourceBitrate { get; init; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public class DownloadOptions : IDownloadOptions, IDisposable
{
public event EventHandler<long> DownloadSpeedChanged;
public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public bool DownloadClipsBookmarks { get; init; }
public long DownloadSpeedBps { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public bool FixupFile { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public bool Downsample { get; init; }
public bool MatchSourceBitrate { get; init; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
{
LibraryBookDto = ArgumentValidator
.EnsureNotNull(libraryBook, nameof(libraryBook))
.ToDto();
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
public async Task<string> SaveClipsAndBookmarks(string fileName)
{
if (DownloadClipsBookmarks)
{
var format = Configuration.Instance.ClipsBookmarksFileFormat;
// no null/empty check for key/iv. unencrypted files do not have them
}
}
var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension);
var api = await LibraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
switch(format)
{
case Configuration.ClipBookmarkFormat.CSV:
RecordExporter.ToCsv(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Xlsx:
RecordExporter.ToXlsx(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Json:
RecordExporter.ToJson(filePath, LibraryBook, records);
break;
}
return filePath;
}
return string.Empty;
}
private readonly IDisposable cancellation;
public void Dispose() => cancellation?.Dispose();
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto();
cancellation =
Configuration.Instance
.ObservePropertyChanged<long>(
nameof(Configuration.DownloadSpeedLimit),
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
}
}
}

View File

@ -49,10 +49,22 @@ namespace FileManager
public T GetNonString<T>(string propertyName)
{
var obj = GetObject(propertyName);
if (obj is null) return default;
if (obj is JValue jValue) return jValue.Value<T>();
if (obj is JObject jObject) return jObject.ToObject<T>();
return (T)obj;
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (jValue.Type == JTokenType.String && typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
? (T)enumVal
: Enum.GetValues(typeof(T)).Cast<T>().First();
}
return jValue.Value<T>();
}
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
public object GetObject(string propertyName)

View File

@ -7,7 +7,7 @@ using System.Linq;
namespace FileManager
{
public class Replacement : ICloneable
public record Replacement
{
public const int FIXED_COUNT = 6;
@ -30,8 +30,6 @@ namespace FileManager
Mandatory = mandatory;
}
public object Clone() => new Replacement(CharacterToReplace, ReplacementString, Description, Mandatory);
public void Update(char charToReplace, string replacementString, string description)
{
ReplacementString = replacementString;
@ -61,10 +59,20 @@ namespace FileManager
[JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters
{
static ReplacementCharacters()
public override bool Equals(object obj)
{
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
{
for (int i = 0; i < Replacements.Count; i++)
if (Replacements[i] != second.Replacements[i])
return false;
return true;
}
return false;
}
public override int GetHashCode() => Replacements.GetHashCode();
public static readonly ReplacementCharacters Default
= IsWindows
? new()

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

@ -8,11 +8,6 @@
Icon="/Assets/libation.ico"
Title="EditTemplateDialog">
<Window.Resources>
<dialogs:BracketEscapeConverter x:Key="BracketEscapeConverter" />
</Window.Resources>
<Grid RowDefinitions="Auto,*,Auto">
<Grid
Grid.Row="0"
@ -27,37 +22,36 @@
<TextBox
Grid.Column="0"
Grid.Row="1"
Text="{Binding workingTemplateText, Mode=TwoWay}" />
Name="userEditTbox"
FontFamily="{Binding FontFamily}"
Text="{Binding UserTemplateText, Mode=TwoWay}" />
<Button
Grid.Column="1"
Grid.Row="1"
Margin="10,0,0,0"
VerticalAlignment="Stretch"
Padding="20,3,20,3"
VerticalContentAlignment="Center"
Content="Reset to Default"
Click="ResetButton_Click" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Border
<DataGrid
Grid.Row="0"
Grid.Column="0"
Margin="5"
BorderBrush="{DynamicResource DataGridGridLinesBrush}"
BorderThickness="1"
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
<DataGrid
GridLinesVisibility="All"
AutoGenerateColumns="False"
DoubleTapped="EditTemplateViewModel_DoubleTapped"
Items="{Binding ListItems}" >
<DataGrid.Columns>
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding TagName, Converter={StaticResource BracketEscapeConverter}}" />
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -68,7 +62,7 @@
<TextPresenter
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Description}" />
VerticalAlignment="Center" Text="{Binding Item2}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@ -76,13 +70,12 @@
</DataGrid.Columns>
</DataGrid>
</Border>
<Grid
Grid.Column="1"
Margin="5"
RowDefinitions="Auto,*,80" HorizontalAlignment="Stretch">
Margin="5,0,5,0"
RowDefinitions="Auto,*,Auto"
HorizontalAlignment="Stretch">
<TextBlock
Margin="5,5,5,10"
@ -94,10 +87,9 @@
BorderThickness="1"
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
<WrapPanel
Grid.Row="1"
Name="wrapPanel"
Orientation="Horizontal" />
<TextBlock
TextWrapping="WrapWithOverflow"
Inlines="{Binding Inlines}" />
</Border>
@ -105,10 +97,9 @@
Grid.Row="2"
Margin="5"
Foreground="Firebrick"
Text="{Binding WarningText}" />
Text="{Binding WarningText}"
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</Grid>
<Button
Grid.Row="2"

View File

@ -1,38 +1,19 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Dinah.Core;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ReactiveUI;
using Avalonia.Controls.Documents;
using Avalonia.Collections;
using Avalonia.Controls;
namespace LibationAvalonia.Dialogs
{
class BracketEscapeConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string str && str[0] != '<' && str[^1] != '>')
return $"<{str}>";
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string str && str[0] == '<' && str[^1] == '>')
return str[1..^2];
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
}
public partial class EditTemplateDialog : DialogWindow
{
// final value. post-validity check
@ -42,26 +23,40 @@ namespace LibationAvalonia.Dialogs
public EditTemplateDialog()
{
InitializeComponent();
_viewModel = new(Configuration.Instance, this.Find<WrapPanel>(nameof(wrapPanel)));
AvaloniaXamlLoader.Load(this);
userEditTbox = this.FindControl<TextBox>(nameof(userEditTbox));
if (Design.IsDesignMode)
{
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
_viewModel = new(Configuration.Instance, Templates.File);
_viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
Title = $"Edit {_viewModel.Template.Name}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
{
_viewModel.template = ArgumentValidator.EnsureNotNull(template, nameof(template));
Title = $"Edit {_viewModel.template.Name}";
_viewModel.Description = _viewModel.template.Description;
ArgumentValidator.EnsureNotNull(template, nameof(template));
_viewModel = new EditTemplateViewModel(Configuration.Instance, template);
_viewModel.resetTextBox(inputTemplateText);
_viewModel.ListItems = _viewModel.template.GetTemplateTags();
Title = $"Edit {template.Name}";
DataContext = _viewModel;
}
private void InitializeComponent()
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
{
AvaloniaXamlLoader.Load(this);
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string>).Item1.Replace("\x200C", "").Replace("...", "");
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
{
if (!await _viewModel.Validate())
@ -75,51 +70,59 @@ namespace LibationAvalonia.Dialogs
=> await SaveAndCloseAsync();
public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.resetTextBox(_viewModel.template.DefaultTemplate);
=> _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
WrapPanel WrapPanel;
public Configuration config { get; }
public EditTemplateViewModel(Configuration configuration, WrapPanel panel)
private readonly Configuration config;
public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName;
public InlineCollection Inlines { get; } = new();
public Templates Template { get; }
public EditTemplateViewModel(Configuration configuration, Templates templates)
{
config = configuration;
WrapPanel = panel;
Template = templates;
Description = templates.Description;
ListItems
= new AvaloniaList<Tuple<string, string>>(
Template
.GetTemplateTags()
.Select(
t => new Tuple<string, string>(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
t.Description)
)
);
}
// hold the work-in-progress value. not guaranteed to be valid
private string _workingTemplateText;
public string workingTemplateText
private string _userTemplateText;
public string UserTemplateText
{
get => _workingTemplateText;
get => _userTemplateText;
set
{
_workingTemplateText = template.Sanitize(value);
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
}
public string workingTemplateText => Template.Sanitize(UserTemplateText);
private string _warningText;
public string WarningText
{
get => _warningText;
set
{
this.RaiseAndSetIfChanged(ref _warningText, value);
}
}
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public Templates template { get; set; }
public string Description { get; set; }
public string Description { get; }
public IEnumerable<TemplateTags> ListItems { get; set; }
public AvaloniaList<Tuple<string, string>> ListItems { get; set; }
public void resetTextBox(string value) => workingTemplateText = value;
public void resetTextBox(string value) => UserTemplateText = value;
public async Task<bool> Validate()
{
if (template.IsValid(workingTemplateText))
if (Template.IsValid(workingTemplateText))
return true;
var errors = template
var errors = Template
.GetErrors(workingTemplateText)
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@ -129,8 +132,8 @@ namespace LibationAvalonia.Dialogs
private void templateTb_TextChanged()
{
var isChapterTitle = template == Templates.ChapterTitle;
var isFolder = template == Templates.Folder;
var isChapterTitle = Template == Templates.ChapterTitle;
var isFolder = Template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
@ -142,7 +145,10 @@ namespace LibationAvalonia.Dialogs
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1"
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
@ -159,18 +165,24 @@ namespace LibationAvalonia.Dialogs
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
isFolder ? workingTemplateText : config.FolderTemplate);
//Path must be rooted for windows to allow long file paths. This is
//only necessary for folder templates because they may contain several
//subdirectories. Without rooting, we won't be allowed to create a
//relative path longer than MAX_PATH
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate));
folder = Path.GetRelativePath(books, folder);
var file
= template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
= Template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
@ -186,63 +198,36 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !template.HasWarnings(workingTemplateText)
= !Template.HasWarnings(workingTemplateText)
? ""
: "Warning:\r\n" +
template
Template
.GetWarnings(workingTemplateText)
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
var list = new List<TextCharacters>();
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
var bold = new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, FontWeight.Bold);
var normal = new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, FontWeight.Normal);
var stringList = new List<(string, FontWeight)>();
Inlines.Clear();
if (isChapterTitle)
{
stringList.Add((chapterTitle, FontWeight.Bold));
}
else
{
stringList.Add((slashWrap(books), FontWeight.Normal));
stringList.Add((sing, FontWeight.Normal));
stringList.Add((slashWrap(folder), isFolder ? FontWeight.Bold : FontWeight.Normal));
stringList.Add((sing, FontWeight.Normal));
stringList.Add((file, !isFolder ? FontWeight.Bold : FontWeight.Normal));
stringList.Add(($".{ext}", FontWeight.Normal));
Inlines.Add(new Run(chapterTitle) { FontWeight = bold });
return;
}
WrapPanel.Children.Clear();
Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
//Avalonia doesn't yet support anything like rich text, so add a new textblock for every word/style
foreach (var item in stringList)
{
var wordsSplit = item.Item1.Split(' ');
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg });
for(int i = 0; i < wordsSplit.Length; i++)
{
var tb = new TextBlock
{
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
TextWrapping = TextWrapping.Wrap,
Text = wordsSplit[i] + (i == wordsSplit.Length - 1 ? "" : " "),
FontWeight = item.Item2
};
Inlines.Add(new Run(sing));
WrapPanel.Children.Add(tb);
}
}
Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}
}
}
}

View File

@ -2,7 +2,7 @@
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="620"
mc:Ignorable="d" d:DesignWidth="850" d:DesignHeight="620"
MinWidth="800" MinHeight="620"
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
@ -102,7 +102,7 @@
Click="OpenLogFolderButton_Click" />
</StackPanel>
<!--
<!--
<CheckBox
Grid.Row="2"
Margin="5"
@ -357,20 +357,20 @@
BorderWidth="1"
Label="Temporary Files Location">
<StackPanel
Margin="5" >
<StackPanel
Margin="5" >
<TextBlock
Margin="0,0,0,10"
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
<TextBlock
Margin="0,0,0,10"
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
<controls:DirectoryOrCustomSelectControl
SubDirectory="Libation"
Directory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}"
KnownDirectories="{Binding DownloadDecryptSettings.KnownDirectories}" />
<controls:DirectoryOrCustomSelectControl
SubDirectory="Libation"
Directory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}"
KnownDirectories="{Binding DownloadDecryptSettings.KnownDirectories}" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
<CheckBox
@ -436,6 +436,26 @@
</CheckBox>
<StackPanel Orientation="Horizontal">
<CheckBox
Margin="0,0,0,5"
IsChecked="{Binding AudioSettings.DownloadClipsBookmarks, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Download Clips, Notes and Bookmarks as" />
</CheckBox>
<controls:WheelComboBox
Margin="5,0,0,0"
IsEnabled="{Binding AudioSettings.DownloadClipsBookmarks}"
Items="{Binding AudioSettings.ClipBookmarkFormats}"
SelectedItem="{Binding AudioSettings.ClipBookmarkFormat}"/>
</StackPanel>
<CheckBox
Margin="0,0,0,5"
IsChecked="{Binding AudioSettings.RetainAaxFile, Mode=TwoWay}">

View File

@ -10,6 +10,7 @@ using Dinah.Core;
using System.Linq;
using FileManager;
using System.IO;
using Avalonia.Collections;
namespace LibationAvalonia.Dialogs
{
@ -381,6 +382,7 @@ namespace LibationAvalonia.Dialogs
public class AudioSettings : ViewModels.ViewModelBase, ISettingsDisplay
{
private bool _downloadClipsBookmarks;
private bool _splitFilesByChapter;
private bool _allowLibationFixup;
private bool _lameTargetBitrate;
@ -401,6 +403,8 @@ namespace LibationAvalonia.Dialogs
AllowLibationFixup = config.AllowLibationFixup;
DownloadCoverArt = config.DownloadCoverArt;
RetainAaxFile = config.RetainAaxFile;
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
SplitFilesByChapter = config.SplitFilesByChapter;
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
StripAudibleBrandAudio = config.StripAudibleBrandAudio;
@ -421,6 +425,8 @@ namespace LibationAvalonia.Dialogs
config.AllowLibationFixup = AllowLibationFixup;
config.DownloadCoverArt = DownloadCoverArt;
config.RetainAaxFile = RetainAaxFile;
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
config.SplitFilesByChapter = SplitFilesByChapter;
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
config.StripAudibleBrandAudio = StripAudibleBrandAudio;
@ -437,6 +443,7 @@ namespace LibationAvalonia.Dialogs
return Task.FromResult(true);
}
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
public string AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
@ -450,6 +457,8 @@ namespace LibationAvalonia.Dialogs
public bool CreateCueSheet { get; set; }
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }
public bool StripAudibleBrandAudio { get; set; }
public bool StripUnabridged { get; set; }

View File

@ -1,54 +0,0 @@
<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="350" d:DesignHeight="200"
x:Class="LibationAvalonia.Dialogs.UpgradeNotification"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="350" MinHeight="200"
MaxWidth="350" MaxHeight="200"
WindowStartupLocation="CenterOwner"
Title="Upgrade Available"
Icon="/Assets/libation.ico">
<Grid Margin="6" RowDefinitions="Auto,Auto,Auto,*,Auto">
<TextBlock Grid.Row="0" FontSize="16" Text="{Binding VersionText}" />
<controls:LinkLabel
Grid.Row="1"
Margin="0,10,0,0"
Text="{Binding DownloadLinkText}"
Tapped="Download_Tapped" />
<controls:LinkLabel
Grid.Row="2"
Margin="0,10,0,0"
Text="Go to the Libation website"
Tapped="Website_Tapped" />
<controls:LinkLabel
Grid.Row="3"
Margin="0,10,0,0"
Text="View the source code on GitHub"
Tapped="Github_Tapped" />
<Grid Grid.Row="4" ColumnDefinitions="*,Auto">
<Button
Grid.Column="0"
HorizontalAlignment="Left"
Content="Don't remind me&#x0a;about this release"
Click="DontRemind_Click" />
<Button
Grid.Column="1"
TabIndex="0"
Padding="20,0,20,0"
VerticalAlignment="Stretch"
HorizontalAlignment="Right"
VerticalContentAlignment="Center"
Content="OK"
Click="OK_Click" />
</Grid>
</Grid>
</Window>

View File

@ -1,42 +0,0 @@
using AppScaffolding;
using Avalonia.Controls;
using Dinah.Core;
using System;
namespace LibationAvalonia.Dialogs
{
public partial class UpgradeNotification : Window
{
public string VersionText { get; }
public string DownloadLinkText { get; }
private string PackageUrl { get; }
public UpgradeNotification()
{
if (Design.IsDesignMode)
{
VersionText = "Libation version 8.7.0 is now available.";
DownloadLinkText = "Download Libation.8.7.0-macos-chardonnay.tar.gz";
DataContext = this;
}
InitializeComponent();
}
public UpgradeNotification(UpgradeProperties upgradeProperties) : this()
{
VersionText = $"Libation version {upgradeProperties.LatestRelease.ToString(3)} is now available.";
PackageUrl = upgradeProperties.ZipUrl;
DownloadLinkText = $"Download {upgradeProperties.ZipName}";
DataContext = this;
}
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.OK);
public void DontRemind_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.Ignore);
public void Download_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url(PackageUrl);
public void Website_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url("ht" + "tps://getlibation.com");
public void Github_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url("ht" + "tps://github.com/rmcrackan/Libation");
}
}

View File

@ -0,0 +1,91 @@
<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="550" d:DesignHeight="450"
x:Class="LibationAvalonia.Dialogs.UpgradeNotificationDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
MinWidth="500" MinHeight="400"
Height="450" Width="550"
WindowStartupLocation="CenterOwner"
Title="Upgrade Available"
Icon="/Assets/libation.ico">
<Grid Margin="6" RowDefinitions="Auto,*,Auto">
<TextBlock
TextWrapping="WrapWithOverflow"
FontSize="15"
Text="{Binding TopMessage}"
IsVisible="{Binding TopMessage, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
<controls:GroupBox
Grid.Row="1"
BorderWidth="2"
Label="Release Information"
Margin="0,10,0,10">
<Grid RowDefinitions="*,Auto,Auto">
<TextBox
Grid.Row="0"
Margin="0,5,0,10"
Grid.ColumnSpan="2"
IsReadOnly="true"
TextWrapping="WrapWithOverflow"
FontSize="12"
HorizontalAlignment="Stretch"
Text="{Binding ReleaseNotes}" />
<TextBlock
Grid.Row="1"
VerticalAlignment="Bottom"
Text="Download Release:" />
<controls:LinkLabel
Grid.Row="1"
Margin="0,0,0,10"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Text="View the source code on GitHub"
Tapped="Github_Tapped" />
<controls:LinkLabel
Grid.Row="2"
Margin="0,0,0,10"
VerticalAlignment="Center"
Text="{Binding DownloadLinkText}"
Tapped="Download_Tapped" />
<controls:LinkLabel
Grid.Row="2"
VerticalAlignment="Center"
HorizontalAlignment="Right"
Text="Go to Libation's website"
Tapped="Website_Tapped" />
</Grid>
</controls:GroupBox>
<Grid
Grid.Row="3"
ColumnDefinitions="*,Auto">
<Button
Grid.Column="0"
HorizontalAlignment="Left"
Content="Don't remind me&#x0a;about this release"
Click="DontRemind_Click" />
<Button
Grid.Column="1"
TabIndex="0"
FontSize="16"
Padding="30,0,30,0"
VerticalAlignment="Stretch"
HorizontalAlignment="Right"
VerticalContentAlignment="Center"
Content="{Binding OkText}"
Click="OK_Click" />
</Grid>
</Grid>
</Window>

View File

@ -0,0 +1,51 @@
using AppScaffolding;
using Avalonia.Controls;
using Dinah.Core;
using System;
namespace LibationAvalonia.Dialogs
{
public partial class UpgradeNotificationDialog : DialogWindow
{
private const string UpdateMessage = "There is a new version available. Would you like to update?\r\n\r\nAfter you close Libation, the upgrade will start automatically.";
public string TopMessage { get; }
public string DownloadLinkText { get; }
public string ReleaseNotes { get; }
public string OkText { get; }
private string PackageUrl { get; }
public UpgradeNotificationDialog()
{
if (Design.IsDesignMode)
{
TopMessage = UpdateMessage;
Title = "Libation version 8.7.0 is now available.";
DownloadLinkText = "Libation.8.7.0-macos-chardonnay.tar.gz";
ReleaseNotes = "New features:\r\n\r\n* 'Remove' now removes forever. Removed books won't be re-added on next scan\r\n* #406 : Right Click Menu for Stop-Light Icon\r\n* #398 : Grid, right-click, copy\r\n* Add option for user to choose custom temp folder\r\n* Build Docker image\r\n\r\nEnhancements\r\n\r\n* Illegal Char Replace dialog in Chardonnay\r\n* Filename character replacement allows replacing any char, not just illegal\r\n* #352 : Better error messages for license denial\r\n* Improve 'cancel download'\r\n\r\nThanks to @Mbucari (u/MSWMan), @pixil98 (u/pixil)\r\n\r\nLibation is a free, open source audible library manager for Windows. Decrypt, backup, organize, and search your audible library\r\n\r\nI intend to keep Libation free and open source, but if you want to leave a tip, who am I to argue?";
OkText = "Yes";
DataContext = this;
}
InitializeComponent();
}
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()
{
Title = $"Libation version {upgradeProperties.LatestRelease.ToString(3)} is now available.";
PackageUrl = upgradeProperties.ZipUrl;
DownloadLinkText = upgradeProperties.ZipName;
ReleaseNotes = upgradeProperties.Notes;
TopMessage = canUpgrade ? UpdateMessage : "";
OkText = canUpgrade ? "Yes" : "OK";
DataContext = this;
}
public void OK_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.OK);
public void DontRemind_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) => Close(DialogResult.Ignore);
public void Download_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url(PackageUrl);
public void Website_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url(LibationScaffolding.WebsiteUrl);
public void Github_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Go.To.Url(LibationScaffolding.RepositoryUrl);
}
}

View File

@ -93,7 +93,7 @@ namespace LibationAvalonia
saveState.Width = (int)form.Bounds.Size.Width;
saveState.Height = (int)form.Bounds.Size.Height;
config.SetObject(form.GetType().Name, saveState);
config.SetNonString(saveState, form.GetType().Name);
}
catch (Exception ex)
{
@ -101,7 +101,7 @@ namespace LibationAvalonia
}
}
class FormSizeAndPosition
private record FormSizeAndPosition
{
public int X;
public int Y;
@ -110,7 +110,6 @@ namespace LibationAvalonia
public bool IsMaximized;
}
public static void HideMinMaxBtns(this Window form)
{
if (Design.IsDesignMode || !Configuration.IsWindows)

View File

@ -57,6 +57,13 @@ namespace LibationAvalonia.ViewModels
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
updateReviewTask.ContinueWith(t =>
{
if (t.Result)
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value);
this.RaiseAndSetIfChanged(ref _myRating, value);
});
}
}
}
@ -75,19 +82,14 @@ namespace LibationAvalonia.ViewModels
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
private Task<bool> updateReviewTask;
private async Task<bool> UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
{
_myRating = rating;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
this.RaisePropertyChanged(nameof(MyRating));
return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating);
}
#endregion
#region Sorting

View File

@ -56,7 +56,7 @@ namespace LibationAvalonia.Views
public void ToggleQueueHideBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
SetQueueCollapseState(_viewModel.QueueOpen);
Configuration.Instance.SetObject(nameof(_viewModel.QueueOpen), _viewModel.QueueOpen);
Configuration.Instance.SetNonString(_viewModel.QueueOpen, nameof(_viewModel.QueueOpen));
}
}
}

View File

@ -53,7 +53,7 @@ namespace LibationAvalonia.Views
AccountsSettingsPersister.Saved += accountsPostSave;
// when autoscan setting is changed, update menu checkbox and run autoscan
Configuration.Instance.AutoScanChanged += startAutoScan;
Configuration.Instance.PropertyChanged += startAutoScan;
}
private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts;
@ -77,9 +77,11 @@ namespace LibationAvalonia.Views
startAutoScan();
}
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
private void startAutoScan(object sender = null, EventArgs e = null)
{
if (Configuration.Instance.AutoScan)
_viewModel.AutoScanChecked = Configuration.Instance.AutoScan;
if (_viewModel.AutoScanChecked)
autoScanTimer.PerformNow();
else
autoScanTimer.Stop();

View File

@ -2,6 +2,7 @@
using LibationAvalonia.Dialogs;
using LibationFileManager;
using System;
using System.IO;
using System.Threading.Tasks;
namespace LibationAvalonia.Views
@ -25,11 +26,11 @@ namespace LibationAvalonia.Views
//Silently download the update in the background, save it to a temp file.
var zipFile = System.IO.Path.GetTempFileName();
var zipFile = Path.GetTempFileName();
try
{
System.Net.Http.HttpClient cli = new();
using var fs = System.IO.File.OpenWrite(zipFile);
using var fs = File.OpenWrite(zipFile);
using var dlStream = await cli.GetStreamAsync(new Uri(upgradeProperties.ZipUrl));
await dlStream.CopyToAsync(fs);
}
@ -44,11 +45,11 @@ namespace LibationAvalonia.Views
void runWindowsUpgrader(string zipFile)
{
var thisExe = Environment.ProcessPath;
var thisDir = System.IO.Path.GetDirectoryName(thisExe);
var thisDir = Path.GetDirectoryName(thisExe);
var zipExtractor = System.IO.Path.Combine(System.IO.Path.GetTempPath(), "ZipExtractor.exe");
var zipExtractor = Path.Combine(Path.GetTempPath(), "ZipExtractor.exe");
System.IO.File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true);
File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true);
var psi = new System.Diagnostics.ProcessStartInfo()
{
@ -69,7 +70,6 @@ namespace LibationAvalonia.Views
};
System.Diagnostics.Process.Start(psi);
Environment.Exit(0);
}
try
@ -77,34 +77,32 @@ namespace LibationAvalonia.Views
var upgradeProperties = await Task.Run(LibationScaffolding.GetLatestRelease);
if (upgradeProperties is null) return;
if (Configuration.IsWindows)
const string ignoreUpdate = "IgnoreUpdate";
var config = Configuration.Instance;
if (config.GetString(ignoreUpdate) == upgradeProperties.LatestRelease.ToString())
return;
var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, Configuration.IsWindows).ShowDialog<DialogResult>(this);
if (notificationResult == DialogResult.Ignore)
config.SetString(upgradeProperties.LatestRelease.ToString(), ignoreUpdate);
if (notificationResult != DialogResult.OK || !Configuration.IsWindows) return;
//Download the update file in the background,
//then wire up installaion on window close.
string zipFile = await downloadUpdate(upgradeProperties);
if (string.IsNullOrEmpty(zipFile) || !File.Exists(zipFile))
return;
Closed += (_, _) =>
{
string zipFile = await downloadUpdate(upgradeProperties);
if (string.IsNullOrEmpty(zipFile) || !System.IO.File.Exists(zipFile))
return;
var result = await MessageBox.Show(this, $"{upgradeProperties.HtmlUrl}\r\n\r\nWould you like to upgrade now?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
if (result == DialogResult.Yes)
if (File.Exists(zipFile))
runWindowsUpgrader(zipFile);
}
else
{
//We're not going to have a solution for in-place upgrade on
//linux/mac, so just notify that an update is available.
const string ignoreUpdate = "IgnoreUpdate";
var config = Configuration.Instance;
if (config.GetObject(ignoreUpdate)?.ToString() == upgradeProperties.LatestRelease.ToString())
return;
var notificationResult = await new UpgradeNotification(upgradeProperties).ShowDialog<DialogResult>(this);
if (notificationResult == DialogResult.Ignore)
config.SetObject(ignoreUpdate, upgradeProperties.LatestRelease.ToString());
}
};
}
catch (Exception ex)
{

View File

@ -1,16 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.ReactiveUI;
using DataLayer;
using Dinah.Core;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels;
using LibationFileManager;
@ -30,8 +26,7 @@ namespace LibationAvalonia.Views
#if DEBUG
this.AttachDevTools();
#endif
this.FindAllControls();
FindAllControls();
// eg: if one of these init'd productsGrid, then another can't reliably subscribe to it
Configure_BackupCounts();

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

View File

@ -36,6 +36,7 @@ namespace LibationFileManager
}
set
{
OnPropertyChanging(nameof(LogLevel), LogLevel, value);
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
@ -45,6 +46,8 @@ namespace LibationFileManager
configuration.Reload();
OnPropertyChanged(nameof(LogLevel), value);
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
{
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),

View File

@ -3,336 +3,250 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
namespace LibationFileManager
{
public partial class Configuration
{
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
public partial class Configuration
{
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private PersistentDictionary persistentDictionary;
private PersistentDictionary persistentDictionary;
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
public T GetNonString<T>([CallerMemberName] string propertyName = "") => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject([CallerMemberName] string propertyName = "") => persistentDictionary.GetObject(propertyName);
public string GetString([CallerMemberName] string propertyName = "") => persistentDictionary.GetString(propertyName);
public void SetNonString(object newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetNonString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public void SetString(string newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
.SingleOrDefault()
as DescriptionAttribute;
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
return attribute?.Description;
}
private object getExistingValue(string propertyName)
{
var property = GetType().GetProperty(propertyName);
if (property is not null) return property.GetValue(this);
return GetObject(propertyName);
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
[Description("Set cover art as the folder's icon. (Windows only)")]
public bool UseCoverAsFolderIcon
{
get => persistentDictionary.GetNonString<bool>(nameof(UseCoverAsFolderIcon));
set => persistentDictionary.SetNonString(nameof(UseCoverAsFolderIcon), value);
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
public bool BetaOptIn
{
get => persistentDictionary.GetNonString<bool>(nameof(BetaOptIn));
set => persistentDictionary.SetNonString(nameof(BetaOptIn), value);
}
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
.SingleOrDefault()
as DescriptionAttribute;
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
{
get => persistentDictionary.GetString(nameof(Books));
set => persistentDictionary.SetString(nameof(Books), value);
}
return attribute?.Description;
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress
{
get => persistentDictionary.GetString(nameof(InProgress));
set => persistentDictionary.SetString(nameof(InProgress), value);
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup
{
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
}
[Description("Set cover art as the folder's icon. (Windows only)")]
public bool UseCoverAsFolderIcon { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Create a cue sheet (.cue)")]
public bool CreateCueSheet
{
get => persistentDictionary.GetNonString<bool>(nameof(CreateCueSheet));
set => persistentDictionary.SetNonString(nameof(CreateCueSheet), value);
}
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
public bool BetaOptIn { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Retain the Aax file after successfully decrypting")]
public bool RetainAaxFile
{
get => persistentDictionary.GetNonString<bool>(nameof(RetainAaxFile));
set => persistentDictionary.SetNonString(nameof(RetainAaxFile), value);
}
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books { get => GetString(); set => SetString(value); }
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter
{
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress { get => GetString(); set => SetString(value); }
[Description("Merge Opening/End Credits into the following/preceding chapters")]
public bool MergeOpeningAndEndCredits
{
get => persistentDictionary.GetNonString<bool>(nameof(MergeOpeningAndEndCredits));
set => persistentDictionary.SetNonString(nameof(MergeOpeningAndEndCredits), value);
}
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
public bool StripUnabridged
{
get => persistentDictionary.GetNonString<bool>(nameof(StripUnabridged));
set => persistentDictionary.SetNonString(nameof(StripUnabridged), value);
}
[Description("Create a cue sheet (.cue)")]
public bool CreateCueSheet { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")]
public bool StripAudibleBrandAudio
{
get => persistentDictionary.GetNonString<bool>(nameof(StripAudibleBrandAudio));
set => persistentDictionary.SetNonString(nameof(StripAudibleBrandAudio), value);
}
[Description("Retain the Aax file after successfully decrypting")]
public bool RetainAaxFile { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy
{
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
}
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Lame encoder target. true = Bitrate, false = Quality")]
public bool LameTargetBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameTargetBitrate));
set => persistentDictionary.SetNonString(nameof(LameTargetBitrate), value);
}
[Description("Merge Opening/End Credits into the following/preceding chapters")]
public bool MergeOpeningAndEndCredits { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono
{
get => persistentDictionary.GetNonString<bool>(nameof(LameDownsampleMono));
set => persistentDictionary.SetNonString(nameof(LameDownsampleMono), value);
}
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
public bool StripUnabridged { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Lame target bitrate [16,320]")]
public int LameBitrate
{
get => persistentDictionary.GetNonString<int>(nameof(LameBitrate));
set => persistentDictionary.SetNonString(nameof(LameBitrate), value);
}
[Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")]
public bool StripAudibleBrandAudio { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameConstantBitrate));
set => persistentDictionary.SetNonString(nameof(LameConstantBitrate), value);
}
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Match the source bitrate?")]
public bool LameMatchSourceBR
{
get => persistentDictionary.GetNonString<bool>(nameof(LameMatchSourceBR));
set => persistentDictionary.SetNonString(nameof(LameMatchSourceBR), value);
}
[Description("Lame encoder target. true = Bitrate, false = Quality")]
public bool LameTargetBitrate { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality
{
get => persistentDictionary.GetNonString<int>(nameof(LameVBRQuality));
set => persistentDictionary.SetNonString(nameof(LameVBRQuality), value);
}
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities
{
get => persistentDictionary.GetNonString<Dictionary<string, bool>>(nameof(GridColumnsVisibilities));
set => persistentDictionary.SetNonString(nameof(GridColumnsVisibilities), value);
}
[Description("Lame target bitrate [16,320]")]
public int LameBitrate { get => GetNonString<int>(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices
{
get => persistentDictionary.GetNonString<Dictionary<string, int>>(nameof(GridColumnsDisplayIndices));
set => persistentDictionary.SetNonString(nameof(GridColumnsDisplayIndices), value);
}
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths
{
get => persistentDictionary.GetNonString<Dictionary<string, int>>(nameof(GridColumnsWidths));
set => persistentDictionary.SetNonString(nameof(GridColumnsWidths), value);
}
[Description("Match the source bitrate?")]
public bool LameMatchSourceBR { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt
{
get => persistentDictionary.GetNonString<bool>(nameof(DownloadCoverArt));
set => persistentDictionary.SetNonString(nameof(DownloadCoverArt), value);
}
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality { get => GetNonString<int>(); set => SetNonString(value); }
public enum BadBookAction
{
[Description("Ask each time what action to take.")]
Ask = 0,
[Description("Stop processing books.")]
Abort = 1,
[Description("Retry book later. Skip for now. Continue processing books.")]
Retry = 2,
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
Ignore = 3
}
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities { get => GetNonString<EquatableDictionary<string, bool>>().Clone(); set => SetNonString(value); }
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook
{
get
{
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
}
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
}
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices { get => GetNonString<EquatableDictionary<string, int>>().Clone(); set => SetNonString(value); }
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
public bool ShowImportedStats
{
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
}
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths { get => GetNonString<EquatableDictionary<string, int>>().Clone(); set => SetNonString(value); }
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
public bool ImportEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
}
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
public bool DownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
}
[Description("Download clips and bookmarks?")]
public bool DownloadClipsBookmarks { get => GetNonString<bool>(); set => SetNonString(value); }
public event EventHandler AutoScanChanged;
[Description("File format to save clips and bookmarks")]
public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString<ClipBookmarkFormat>(); set => SetNonString(value); }
[Description("Automatically run periodic scans in the background?")]
public bool AutoScan
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoScan));
set
{
if (AutoScan != value)
{
persistentDictionary.SetNonString(nameof(AutoScan), value);
AutoScanChanged?.Invoke(null, null);
}
}
}
[JsonConverter(typeof(StringEnumConverter))]
public enum ClipBookmarkFormat
{
[Description("Comma-separated values")]
CSV,
[Description("Microsoft Excel Spreadsheet")]
Xlsx,
[Description("JavaScript Object Notation (JSON)")]
Json
}
[Description("Auto download books? After scan, download new books in 'checked' accounts.")]
// poorly named setting. Should just be 'AutoDownload'. It is NOT episode specific
public bool AutoDownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoDownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
}
[JsonConverter(typeof(StringEnumConverter))]
public enum BadBookAction
{
[Description("Ask each time what action to take.")]
Ask = 0,
[Description("Stop processing books.")]
Abort = 1,
[Description("Retry book later. Skip for now. Continue processing books.")]
Retry = 2,
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
Ignore = 3
}
[Description("Save all podcast episodes in a series to the series parent folder?")]
public bool SavePodcastsToParentFolder
{
get => persistentDictionary.GetNonString<bool>(nameof(SavePodcastsToParentFolder));
set => persistentDictionary.SetNonString(nameof(SavePodcastsToParentFolder), value);
}
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook { get => GetNonString<BadBookAction>(); set => SetNonString(value); }
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
public bool ShowImportedStats { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
public bool ImportEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
public bool DownloadEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Automatically run periodic scans in the background?")]
public bool AutoScan { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Auto download books? After scan, download new books in 'checked' accounts.")]
// poorly named setting. Should just be 'AutoDownload'. It is NOT episode specific
public bool AutoDownloadEpisodes { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Save all podcast episodes in a series to the series parent folder?")]
public bool SavePodcastsToParentFolder { get => GetNonString<bool>(); set => SetNonString(value); }
[Description("Global download speed limit in bytes per second.")]
public long DownloadSpeedLimit
{
get
{
AaxDecrypter.NetworkFileStream.GlobalSpeedLimit = persistentDictionary.GetNonString<long>(nameof(DownloadSpeedLimit));
return AaxDecrypter.NetworkFileStream.GlobalSpeedLimit;
get
{
var limit = GetNonString<long>();
return limit <= 0 ? 0 : Math.Max(limit, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND);
}
set
{
var limit = value <= 0 ? 0 : Math.Max(value, AaxDecrypter.NetworkFileStream.MIN_BYTES_PER_SECOND);
SetNonString(limit);
}
set
{
AaxDecrypter.NetworkFileStream.GlobalSpeedLimit = value;
persistentDictionary.SetNonString(nameof(DownloadSpeedLimit), AaxDecrypter.NetworkFileStream.GlobalSpeedLimit);
}
}
#region templates: custom file naming
[Description("Edit how filename characters are replaced")]
public ReplacementCharacters ReplacementCharacters
{
get => persistentDictionary.GetNonString<ReplacementCharacters>(nameof(ReplacementCharacters));
set => persistentDictionary.SetNonString(nameof(ReplacementCharacters), value);
}
public ReplacementCharacters ReplacementCharacters { get => GetNonString<ReplacementCharacters>(); set => SetNonString(value); }
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
}
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
}
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => getTemplate(nameof(FileTemplate), Templates.File);
set => setTemplate(nameof(FileTemplate), Templates.File, value);
}
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => getTemplate(nameof(FileTemplate), Templates.File);
set => setTemplate(nameof(FileTemplate), Templates.File, value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
}
[Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate
{
get => getTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle);
set => setTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle, value);
}
[Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate
{
get => getTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle);
set => setTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle, value);
}
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
private void setTemplate(string settingName, Templates templ, string newValue)
{
var template = newValue?.Trim();
if (templ.IsValid(template))
persistentDictionary.SetString(settingName, template);
}
#endregion
}
private string getTemplate(string settingName, Templates templ) => templ.GetValid(GetString(settingName));
private void setTemplate(string settingName, Templates templ, string newValue)
{
var template = newValue?.Trim();
if (templ.IsValid(template))
SetString(template, settingName);
}
#endregion
}
}

View File

@ -0,0 +1,31 @@
using System.Collections.Generic;
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
{
/*
* Use this type in the getter for any Dictionary<TKey, TValue> settings,
* and be sure to clone it before returning. This allows Configuration to
* accurately detect if any of the Dictionary's elements have changed.
*/
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue>
{
public EquatableDictionary(IEnumerable<KeyValuePair<TKey, TValue>> keyValuePairs) : base(keyValuePairs) { }
public EquatableDictionary<TKey, TValue> Clone() => new(this);
public override bool Equals(object obj)
{
if (obj is Dictionary<TKey, TValue> dic && Count == dic.Count)
{
foreach (var pair in this)
if (!dic.TryGetValue(pair.Key, out var value) || !pair.Value.Equals(value))
return false;
return true;
}
return false;
}
public override int GetHashCode() => base.GetHashCode();
}
}
}

View File

@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;

View File

@ -20,10 +20,6 @@
</Compile>
</ItemGroup>
<ItemGroup>
<Folder Include="OSInterop\" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>

View File

@ -0,0 +1,335 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Reflection;
namespace LibationFileManager
{
#region Useage
/*
* USEAGE
*************************
* *
* Event Filter Mode *
* *
*************************
propertyChangeFilter.PropertyChanged += MyPropertiesChanged;
[PropertyChangeFilter("MyProperty1")]
[PropertyChangeFilter("MyProperty2")]
void MyPropertiesChanged(object sender, PropertyChangedEventArgsEx e)
{
// Only properties whose names match either "MyProperty1"
// or "MyProperty2" will fire this event handler.
}
******
* OR *
******
propertyChangeFilter.PropertyChanged +=
[PropertyChangeFilter("MyProperty1")]
[PropertyChangeFilter("MyProperty2")]
(_, _) =>
{
// Only properties whose names match either "MyProperty1"
// or "MyProperty2" will fire this event handler.
};
*************************
* *
* Observable Mode *
* *
*************************
using var cancellation = propertyChangeFilter.ObservePropertyChanging<int>("MyProperty", MyPropertyChanging);
void MyPropertyChanging(int oldValue, int newValue)
{
// Only the property whose name match
// "MyProperty" will fire this method.
}
//The observer is delisted when cancellation is disposed
******
* OR *
******
using var cancellation = propertyChangeFilter.ObservePropertyChanged<bool>("MyProperty", s =>
{
// Only the property whose name match
// "MyProperty" will fire this action.
});
//The observer is delisted when cancellation is disposed
*/
#endregion
public abstract class PropertyChangeFilter
{
private readonly Dictionary<string, List<Delegate>> propertyChangedActions = new();
private readonly Dictionary<string, List<Delegate>> propertyChangingActions = new();
private readonly List<(PropertyChangedEventHandlerEx subscriber, PropertyChangedEventHandlerEx wrapper)> changedFilters = new();
private readonly List<(PropertyChangingEventHandlerEx subscriber, PropertyChangingEventHandlerEx wrapper)> changingFilters = new();
public PropertyChangeFilter()
{
PropertyChanging += Configuration_PropertyChanging;
PropertyChanged += Configuration_PropertyChanged;
}
#region Events
protected void OnPropertyChanged(string propertyName, object newValue)
=> _propertyChanged?.Invoke(this, new(propertyName, newValue));
protected void OnPropertyChanging(string propertyName, object oldValue, object newValue)
=> _propertyChanging?.Invoke(this, new(propertyName, oldValue, newValue));
private PropertyChangedEventHandlerEx _propertyChanged;
private PropertyChangingEventHandlerEx _propertyChanging;
public event PropertyChangedEventHandlerEx PropertyChanged
{
add
{
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
if (attributes.Any())
{
var matches = attributes.Select(a => a.PropertyName).ToArray();
void filterer(object s, PropertyChangedEventArgsEx e)
{
if (e.PropertyName.In(matches)) value(s, e);
}
changedFilters.Add((value, filterer));
_propertyChanged += filterer;
}
else
_propertyChanged += value;
}
remove
{
var del = changedFilters.LastOrDefault(d => d.subscriber == value);
if (del == default)
_propertyChanged -= value;
else
{
_propertyChanged -= del.wrapper;
changedFilters.Remove(del);
}
}
}
public event PropertyChangingEventHandlerEx PropertyChanging
{
add
{
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
if (attributes.Any())
{
var matches = attributes.Select(a => a.PropertyName).ToArray();
void filterer(object s, PropertyChangingEventArgsEx e)
{
if (e.PropertyName.In(matches)) value(s, e);
}
changingFilters.Add((value, filterer));
_propertyChanging += filterer;
}
else
_propertyChanging += value;
}
remove
{
var del = changingFilters.LastOrDefault(d => d.subscriber == value);
if (del == default)
_propertyChanging -= value;
else
{
_propertyChanging -= del.wrapper;
changingFilters.Remove(del);
}
}
}
private static T[] getAttributes<T>(MethodInfo methodInfo) where T : Attribute
=> Attribute.GetCustomAttributes(methodInfo, typeof(T)) as T[];
#endregion
#region Observables
/// <summary>
/// Clear all subscriptions to Property<b>Changed</b> for <paramref name="propertyName"/>
/// </summary>
public void ClearChangedSubscriptions(string propertyName)
{
if (propertyChangedActions.ContainsKey(propertyName)
&& propertyChangedActions[propertyName] is not null)
propertyChangedActions[propertyName].Clear();
}
/// <summary>
/// Clear all subscriptions to Property<b>Changing</b> for <paramref name="propertyName"/>
/// </summary>
public void ClearChangingSubscriptions(string propertyName)
{
if (propertyChangingActions.ContainsKey(propertyName)
&& propertyChangingActions[propertyName] is not null)
propertyChangingActions[propertyName].Clear();
}
/// <summary>
/// Add an action to be executed when a property's value has changed
/// </summary>
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
/// <param name="action">Action to be executed with the NewValue as a parameter</param>
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
public IDisposable ObservePropertyChanged<T>(string propertyName, Action<T> action)
{
validateSubscriber<T>(propertyName, action);
if (!propertyChangedActions.ContainsKey(propertyName))
propertyChangedActions.Add(propertyName, new List<Delegate>());
var actionlist = propertyChangedActions[propertyName];
if (!actionlist.Contains(action))
actionlist.Add(action);
return new Unsubscriber(actionlist, action);
}
/// <summary>
/// Add an action to be executed when a property's value is changing
/// </summary>
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
/// <param name="action">Action to be executed with OldValue and NewValue as parameters</param>
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
public IDisposable ObservePropertyChanging<T>(string propertyName, Action<T, T> action)
{
validateSubscriber<T>(propertyName, action);
if (!propertyChangingActions.ContainsKey(propertyName))
propertyChangingActions.Add(propertyName, new List<Delegate>());
var actionlist = propertyChangingActions[propertyName];
if (!actionlist.Contains(action))
actionlist.Add(action);
return new Unsubscriber(actionlist, action);
}
private void validateSubscriber<T>(string propertyName, Delegate action)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(propertyName, nameof(propertyName));
ArgumentValidator.EnsureNotNull(action, nameof(action));
var propertyInfo = GetType().GetProperty(propertyName);
if (propertyInfo is null)
throw new MissingMemberException($"{nameof(Configuration)}.{propertyName} does not exist.");
if (propertyInfo.PropertyType != typeof(T))
throw new InvalidCastException($"{nameof(Configuration)}.{propertyName} is {propertyInfo.PropertyType}, but parameter is {typeof(T)}.");
}
private void Configuration_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
if (propertyChangedActions.ContainsKey(e.PropertyName))
{
foreach (var action in propertyChangedActions[e.PropertyName])
{
action.DynamicInvoke(e.NewValue);
}
}
}
private void Configuration_PropertyChanging(object sender, PropertyChangingEventArgsEx e)
{
if (propertyChangingActions.ContainsKey(e.PropertyName))
{
foreach (var action in propertyChangingActions[e.PropertyName])
{
action.DynamicInvoke(e.OldValue, e.NewValue);
}
}
}
private class Unsubscriber : IDisposable
{
private List<Delegate> _observers;
private Delegate _observer;
internal Unsubscriber(List<Delegate> observers, Delegate observer)
{
_observers = observers;
_observer = observer;
}
public void Dispose()
{
if (_observers.Contains(_observer))
_observers.Remove(_observer);
}
}
#endregion
}
public delegate void PropertyChangedEventHandlerEx(object sender, PropertyChangedEventArgsEx e);
public delegate void PropertyChangingEventHandlerEx(object sender, PropertyChangingEventArgsEx e);
public class PropertyChangedEventArgsEx : PropertyChangedEventArgs
{
public object NewValue { get; }
public PropertyChangedEventArgsEx(string propertyName, object newValue) : base(propertyName)
{
NewValue = newValue;
}
}
public class PropertyChangingEventArgsEx : PropertyChangingEventArgs
{
public object OldValue { get; }
public object NewValue { get; }
public PropertyChangingEventArgsEx(string propertyName, object oldValue, object newValue) : base(propertyName)
{
OldValue = oldValue;
NewValue = newValue;
}
}
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
public class PropertyChangeFilterAttribute : Attribute
{
public string PropertyName { get; }
public PropertyChangeFilterAttribute(string propertyName)
{
PropertyName = propertyName;
}
}
}

View File

@ -275,6 +275,8 @@ namespace LibationFileManager
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
{
if (string.IsNullOrWhiteSpace(template)) return string.Empty;
replacements ??= Configuration.Instance.ReplacementCharacters;
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
@ -319,7 +321,8 @@ namespace LibationFileManager
public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
if (string.IsNullOrEmpty(template)) return string.Empty;
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
var fileNamingTemplate = new MetadataNamingTemplate(template);

View File

@ -0,0 +1,246 @@
namespace LibationWinForms.Dialogs
{
partial class BookRecordsDialog
{
/// <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()
{
this.components = new System.ComponentModel.Container();
this.syncBindingSource = new LibationWinForms.GridView.SyncBindingSource(this.components);
this.dataGridView1 = new System.Windows.Forms.DataGridView();
this.checkboxColumn = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.typeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.createdColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.startTimeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.modifiedColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.endTimeColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.noteColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.titleColumn = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.checkAllBtn = new System.Windows.Forms.Button();
this.uncheckAllBtn = new System.Windows.Forms.Button();
this.deleteCheckedBtn = new System.Windows.Forms.Button();
this.exportAllBtn = new System.Windows.Forms.Button();
this.exportCheckedBtn = new System.Windows.Forms.Button();
this.reloadAllBtn = new System.Windows.Forms.Button();
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).BeginInit();
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).BeginInit();
this.SuspendLayout();
//
// dataGridView1
//
this.dataGridView1.AllowUserToAddRows = false;
this.dataGridView1.AllowUserToDeleteRows = false;
this.dataGridView1.AllowUserToResizeRows = false;
this.dataGridView1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.checkboxColumn,
this.typeColumn,
this.createdColumn,
this.startTimeColumn,
this.modifiedColumn,
this.endTimeColumn,
this.noteColumn,
this.titleColumn});
this.dataGridView1.Location = new System.Drawing.Point(0, 0);
this.dataGridView1.Name = "dataGridView1";
this.dataGridView1.RowHeadersVisible = false;
this.dataGridView1.RowTemplate.Height = 25;
this.dataGridView1.Size = new System.Drawing.Size(491, 291);
this.dataGridView1.TabIndex = 0;
//
// checkboxColumn
//
this.checkboxColumn.DataPropertyName = "IsChecked";
this.checkboxColumn.HeaderText = "Checked";
this.checkboxColumn.Name = "checkboxColumn";
this.checkboxColumn.Width = 60;
//
// typeColumn
//
this.typeColumn.DataPropertyName = "Type";
this.typeColumn.HeaderText = "Type";
this.typeColumn.Name = "typeColumn";
this.typeColumn.ReadOnly = true;
this.typeColumn.Width = 80;
//
// createdColumn
//
this.createdColumn.DataPropertyName = "Created";
this.createdColumn.HeaderText = "Created";
this.createdColumn.Name = "createdColumn";
this.createdColumn.ReadOnly = true;
//
// startTimeColumn
//
this.startTimeColumn.DataPropertyName = "Start";
this.startTimeColumn.HeaderText = "Start";
this.startTimeColumn.Name = "startTimeColumn";
this.startTimeColumn.ReadOnly = true;
//
// modifiedColumn
//
this.modifiedColumn.DataPropertyName = "Modified";
this.modifiedColumn.HeaderText = "Modified";
this.modifiedColumn.Name = "modifiedColumn";
this.modifiedColumn.ReadOnly = true;
//
// endTimeColumn
//
this.endTimeColumn.DataPropertyName = "End";
this.endTimeColumn.HeaderText = "End";
this.endTimeColumn.Name = "endTimeColumn";
this.endTimeColumn.ReadOnly = true;
//
// noteColumn
//
this.noteColumn.DataPropertyName = "Note";
this.noteColumn.HeaderText = "Note";
this.noteColumn.Name = "noteColumn";
this.noteColumn.ReadOnly = true;
//
// titleColumn
//
this.titleColumn.DataPropertyName = "Title";
this.titleColumn.HeaderText = "Title";
this.titleColumn.Name = "titleColumn";
this.titleColumn.ReadOnly = true;
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(12, 297);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(80, 23);
this.checkAllBtn.TabIndex = 1;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(12, 326);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(80, 23);
this.uncheckAllBtn.TabIndex = 2;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// deleteCheckedBtn
//
this.deleteCheckedBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deleteCheckedBtn.Location = new System.Drawing.Point(115, 297);
this.deleteCheckedBtn.Margin = new System.Windows.Forms.Padding(20, 3, 3, 3);
this.deleteCheckedBtn.Name = "deleteCheckedBtn";
this.deleteCheckedBtn.Size = new System.Drawing.Size(97, 23);
this.deleteCheckedBtn.TabIndex = 3;
this.deleteCheckedBtn.Text = "Delete Checked";
this.deleteCheckedBtn.UseVisualStyleBackColor = true;
this.deleteCheckedBtn.Click += new System.EventHandler(this.deleteCheckedBtn_Click);
//
// exportAllBtn
//
this.exportAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.exportAllBtn.Location = new System.Drawing.Point(378, 326);
this.exportAllBtn.Name = "exportAllBtn";
this.exportAllBtn.Size = new System.Drawing.Size(101, 23);
this.exportAllBtn.TabIndex = 4;
this.exportAllBtn.Text = "Export All";
this.exportAllBtn.UseVisualStyleBackColor = true;
this.exportAllBtn.Click += new System.EventHandler(this.exportAllBtn_Click);
//
// exportCheckedBtn
//
this.exportCheckedBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.exportCheckedBtn.Location = new System.Drawing.Point(378, 297);
this.exportCheckedBtn.Name = "exportCheckedBtn";
this.exportCheckedBtn.Size = new System.Drawing.Size(101, 23);
this.exportCheckedBtn.TabIndex = 5;
this.exportCheckedBtn.Text = "Export Checked";
this.exportCheckedBtn.UseVisualStyleBackColor = true;
this.exportCheckedBtn.Click += new System.EventHandler(this.exportCheckedBtn_Click);
//
// reloadAllBtn
//
this.reloadAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.reloadAllBtn.Location = new System.Drawing.Point(115, 326);
this.reloadAllBtn.Margin = new System.Windows.Forms.Padding(20, 3, 3, 3);
this.reloadAllBtn.Name = "reloadAllBtn";
this.reloadAllBtn.Size = new System.Drawing.Size(97, 23);
this.reloadAllBtn.TabIndex = 6;
this.reloadAllBtn.Text = "Reload All";
this.reloadAllBtn.UseVisualStyleBackColor = true;
this.reloadAllBtn.Click += new System.EventHandler(this.reloadAllBtn_Click);
//
// BookRecordsDialog
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(491, 361);
this.Controls.Add(this.reloadAllBtn);
this.Controls.Add(this.exportCheckedBtn);
this.Controls.Add(this.exportAllBtn);
this.Controls.Add(this.deleteCheckedBtn);
this.Controls.Add(this.uncheckAllBtn);
this.Controls.Add(this.checkAllBtn);
this.Controls.Add(this.dataGridView1);
this.KeyPreview = true;
this.MaximizeBox = false;
this.MinimizeBox = false;
this.MinimumSize = new System.Drawing.Size(507, 400);
this.Name = "BookRecordsDialog";
this.Text = "Book Dialog";
this.Shown += new System.EventHandler(this.BookRecordsDialog_Shown);
((System.ComponentModel.ISupportInitialize)(this.syncBindingSource)).EndInit();
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
this.ResumeLayout(false);
}
#endregion
private System.Windows.Forms.DataGridView dataGridView1;
private LibationWinForms.GridView.SyncBindingSource syncBindingSource;
private System.Windows.Forms.Button checkAllBtn;
private System.Windows.Forms.Button uncheckAllBtn;
private System.Windows.Forms.Button deleteCheckedBtn;
private System.Windows.Forms.Button exportAllBtn;
private System.Windows.Forms.Button exportCheckedBtn;
private System.Windows.Forms.DataGridViewCheckBoxColumn checkboxColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn typeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn createdColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn startTimeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn modifiedColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn endTimeColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn noteColumn;
private System.Windows.Forms.DataGridViewTextBoxColumn titleColumn;
private System.Windows.Forms.Button reloadAllBtn;
}
}

View File

@ -0,0 +1,281 @@
using ApplicationServices;
using AudibleApi.Common;
using DataLayer;
using FileLiberator;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Data;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms.Dialogs
{
public partial class BookRecordsDialog : Form
{
private readonly Func<ScrollBar> VScrollBar;
private readonly LibraryBook libraryBook;
private BookRecordBindingList bookRecordEntries;
public BookRecordsDialog()
{
InitializeComponent();
if (!DesignMode)
{
//Prevent the designer from auto-generating columns
dataGridView1.AutoGenerateColumns = false;
dataGridView1.DataSource = syncBindingSource;
}
this.SetLibationIcon();
VScrollBar =
typeof(DataGridView)
.GetProperty("VerticalScrollBar", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)
.GetMethod
.CreateDelegate<Func<ScrollBar>>(dataGridView1);
this.RestoreSizeAndLocation(LibationFileManager.Configuration.Instance);
FormClosing += (_, _) => this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance);
}
public BookRecordsDialog(LibraryBook libraryBook) : this()
{
this.libraryBook = libraryBook;
Text = $"{libraryBook.Book.Title} - Clips and Bookmarks";
}
private async void BookRecordsDialog_Shown(object sender, EventArgs e)
{
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries = new BookRecordBindingList(records.Select(r => new BookRecordEntry(r)));
}
catch(Exception ex)
{
Serilog.Log.Error(ex, "Failed to retrieve records for {libraryBook}", libraryBook);
bookRecordEntries = new();
}
finally
{
syncBindingSource.DataSource = bookRecordEntries;
//Autosize columns and resize form to column width so no horizontal scroll bar is necessary.
dataGridView1.AutoResizeColumns(DataGridViewAutoSizeColumnsMode.AllCells);
var columnWidth = dataGridView1.Columns.OfType<DataGridViewColumn>().Sum(c => c.Width);
Width = Width - dataGridView1.Width + columnWidth + dataGridView1.Margin.Right + (VScrollBar().Visible? VScrollBar().ClientSize.Width : 0);
}
}
#region Buttons
private void setControlEnabled(object control, bool enabled)
{
if (control is Control c)
{
if (c.InvokeRequired)
c.Invoke(new MethodInvoker(() =>
{
c.Enabled = enabled;
c.Focus();
}));
else
{
c.Enabled = enabled;
c.Focus();
}
}
}
private async void exportCheckedBtn_Click(object sender, EventArgs e)
{
setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record));
setControlEnabled(sender, true);
}
private async void exportAllBtn_Click(object sender, EventArgs e)
{
setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Select(r => r.Record));
setControlEnabled(sender, true);
}
private void uncheckAllBtn_Click(object sender, EventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = false;
}
private void checkAllBtn_Click(object sender, EventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = true;
}
private async void deleteCheckedBtn_Click(object sender, EventArgs e)
{
var records = bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record).ToList();
if (!records.Any()) return;
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 { setControlEnabled(sender, true); }
if (!success)
MessageBox.Show(this, $"Libation was unable to delete the {records.Count} selected records", "Deletion Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
private async void reloadAllBtn_Click(object sender, EventArgs e)
{
setControlEnabled(sender, false);
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries = new BookRecordBindingList(records.Select(r => new BookRecordEntry(r)));
syncBindingSource.DataSource = bookRecordEntries;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, ex.Message);
MessageBox.Show(this, $"Libation was unable to to reload records", "Reload Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally { setControlEnabled(sender, true); }
}
#endregion
private async Task saveRecords(IEnumerable<IRecord> records)
{
if (!records.Any()) return;
try
{
var saveFileDialog =
Invoke(() => new SaveFileDialog
{
Title = "Where to export records",
AddExtension = true,
FileName = $"{libraryBook.Book.Title} - Records",
DefaultExt = "xlsx",
Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*"
});
if (Invoke(saveFileDialog.ShowDialog) != DialogResult.OK)
return;
// FilterIndex is 1-based, NOT 0-based
switch (saveFileDialog.FilterIndex)
{
case 1: // xlsx
default:
await Task.Run(() => RecordExporter.ToXlsx(saveFileDialog.FileName, records));
break;
case 2: // csv
await Task.Run(() => RecordExporter.ToCsv(saveFileDialog.FileName, records));
break;
case 3: // json
await Task.Run(() => RecordExporter.ToJson(saveFileDialog.FileName, libraryBook, records));
break;
}
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
}
}
protected override void OnKeyDown(KeyEventArgs e)
{
if (e.KeyCode == Keys.Escape) Close();
base.OnKeyDown(e);
}
#region DataGridView Bindings
private class BookRecordBindingList : BindingList<BookRecordEntry>
{
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 BookRecordBindingList() : base(new List<BookRecordEntry>()) { }
public BookRecordBindingList(IEnumerable<BookRecordEntry> records) : base(records.ToList()) { }
protected override void ApplySortCore(PropertyDescriptor prop, ListSortDirection direction)
{
var itemsList = (List<BookRecordEntry>)Items;
var sorted =
direction is 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));
}
}
private class BookRecordEntry : GridView.AsyncNotifyPropertyChanged
{
private const string DateFormat = "yyyy-MM-dd HH\\:mm";
private bool _ischecked;
public IRecord Record { get; }
public bool IsChecked { get => _ischecked; set { _ischecked = value; NotifyPropertyChanged(); } }
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

@ -0,0 +1,87 @@
<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>
<metadata name="syncBindingSource.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
<value>17, 17</value>
</metadata>
<metadata name="checkboxColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="typeColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="createdColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="startTimeColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="modifiedColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="endTimeColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="noteColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="titleColumn.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
</root>

View File

@ -30,7 +30,7 @@ namespace LibationWinForms.Dialogs
var r = replacements[i];
int row = dataGridView1.Rows.Add(r.CharacterToReplace.ToString(), r.ReplacementString, r.Description);
dataGridView1.Rows[row].Tag = r.Clone();
dataGridView1.Rows[row].Tag = r with { };
if (r.Mandatory)

View File

@ -103,13 +103,16 @@
this.listView1.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
this.columnHeader1,
this.columnHeader2});
this.listView1.HideSelection = false;
this.listView1.FullRowSelect = true;
this.listView1.GridLines = true;
this.listView1.Location = new System.Drawing.Point(12, 56);
this.listView1.MultiSelect = false;
this.listView1.Name = "listView1";
this.listView1.Size = new System.Drawing.Size(328, 283);
this.listView1.TabIndex = 3;
this.listView1.UseCompatibleStateImageBehavior = false;
this.listView1.View = System.Windows.Forms.View.Details;
this.listView1.DoubleClick += new System.EventHandler(this.listView1_DoubleClick);
//
// columnHeader1
//

View File

@ -98,11 +98,17 @@ namespace LibationWinForms.Dialogs
};
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
isFolder ? workingTemplateText : config.FolderTemplate);
//Path must be rooted for windows to allow long file paths. This is
//only necessary for folder templates because they may contain several
//subdirectories. Without rooting, we won't be allowed to create a
//relative path longer than MAX_PATH
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate));
folder = Path.GetRelativePath(books, folder);
var file
= template == Templates.ChapterFile
@ -197,5 +203,16 @@ namespace LibationWinForms.Dialogs
this.DialogResult = DialogResult.Cancel;
this.Close();
}
private void listView1_DoubleClick(object sender, EventArgs e)
{
var itemText = listView1.SelectedItems[0].Text.Replace("...", "");
var text = templateTb.Text;
var selStart = Math.Min(Math.Max(0, templateTb.SelectionStart), text.Length);
templateTb.Text = text.Insert(selStart, itemText);
templateTb.SelectionStart = selStart + itemText.Length;
}
}
}

View File

@ -17,9 +17,19 @@ namespace LibationWinForms.Dialogs
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
clipsBookmarksFormatCb.Items.AddRange(
new object[]
{
Configuration.ClipBookmarkFormat.CSV,
Configuration.ClipBookmarkFormat.Xlsx,
Configuration.ClipBookmarkFormat.Json
});
allowLibationFixupCbox.Checked = config.AllowLibationFixup;
createCueSheetCbox.Checked = config.CreateCueSheet;
downloadCoverArtCbox.Checked = config.DownloadCoverArt;
downloadClipsBookmarksCbox.Checked = config.DownloadClipsBookmarks;
clipsBookmarksFormatCb.SelectedItem = config.ClipsBookmarksFileFormat;
retainAaxFileCbox.Checked = config.RetainAaxFile;
splitFilesByChapterCbox.Checked = config.SplitFilesByChapter;
mergeOpeningEndCreditsCbox.Checked = config.MergeOpeningAndEndCredits;
@ -44,6 +54,7 @@ namespace LibationWinForms.Dialogs
convertFormatRb_CheckedChanged(this, EventArgs.Empty);
allowLibationFixupCbox_CheckedChanged(this, EventArgs.Empty);
splitFilesByChapterCbox_CheckedChanged(this, EventArgs.Empty);
downloadClipsBookmarksCbox_CheckedChanged(this, EventArgs.Empty);
}
private void Save_AudioSettings(Configuration config)
@ -51,6 +62,8 @@ namespace LibationWinForms.Dialogs
config.AllowLibationFixup = allowLibationFixupCbox.Checked;
config.CreateCueSheet = createCueSheetCbox.Checked;
config.DownloadCoverArt = downloadCoverArtCbox.Checked;
config.DownloadClipsBookmarks = downloadClipsBookmarksCbox.Checked;
config.ClipsBookmarksFileFormat = (Configuration.ClipBookmarkFormat)clipsBookmarksFormatCb.SelectedItem;
config.RetainAaxFile = retainAaxFileCbox.Checked;
config.SplitFilesByChapter = splitFilesByChapterCbox.Checked;
config.MergeOpeningAndEndCredits = mergeOpeningEndCreditsCbox.Checked;
@ -68,6 +81,12 @@ namespace LibationWinForms.Dialogs
config.ChapterTitleTemplate = chapterTitleTemplateTb.Text;
}
private void downloadClipsBookmarksCbox_CheckedChanged(object sender, EventArgs e)
{
clipsBookmarksFormatCb.Enabled = downloadClipsBookmarksCbox.Checked;
}
private void lameTargetRb_CheckedChanged(object sender, EventArgs e)
{
lameBitrateGb.Enabled = lameTargetBitrateRb.Checked;

View File

@ -73,6 +73,8 @@
this.folderTemplateTb = new System.Windows.Forms.TextBox();
this.folderTemplateLbl = new System.Windows.Forms.Label();
this.tab4AudioFileOptions = new System.Windows.Forms.TabPage();
this.clipsBookmarksFormatCb = new System.Windows.Forms.ComboBox();
this.downloadClipsBookmarksCbox = new System.Windows.Forms.CheckBox();
this.audiobookFixupsGb = new System.Windows.Forms.GroupBox();
this.stripUnabridgedCbox = new System.Windows.Forms.CheckBox();
this.chapterTitleTemplateGb = new System.Windows.Forms.GroupBox();
@ -281,7 +283,7 @@
this.allowLibationFixupCbox.AutoSize = true;
this.allowLibationFixupCbox.Checked = true;
this.allowLibationFixupCbox.CheckState = System.Windows.Forms.CheckState.Checked;
this.allowLibationFixupCbox.Location = new System.Drawing.Point(19, 118);
this.allowLibationFixupCbox.Location = new System.Drawing.Point(19, 143);
this.allowLibationFixupCbox.Name = "allowLibationFixupCbox";
this.allowLibationFixupCbox.Size = new System.Drawing.Size(163, 19);
this.allowLibationFixupCbox.TabIndex = 10;
@ -633,6 +635,8 @@
//
// tab4AudioFileOptions
//
this.tab4AudioFileOptions.Controls.Add(this.clipsBookmarksFormatCb);
this.tab4AudioFileOptions.Controls.Add(this.downloadClipsBookmarksCbox);
this.tab4AudioFileOptions.Controls.Add(this.audiobookFixupsGb);
this.tab4AudioFileOptions.Controls.Add(this.chapterTitleTemplateGb);
this.tab4AudioFileOptions.Controls.Add(this.lameOptionsGb);
@ -649,6 +653,26 @@
this.tab4AudioFileOptions.Text = "Audio File Options";
this.tab4AudioFileOptions.UseVisualStyleBackColor = true;
//
// clipsBookmarksFormatCb
//
this.clipsBookmarksFormatCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
this.clipsBookmarksFormatCb.FormattingEnabled = true;
this.clipsBookmarksFormatCb.Location = new System.Drawing.Point(269, 64);
this.clipsBookmarksFormatCb.Name = "clipsBookmarksFormatCb";
this.clipsBookmarksFormatCb.Size = new System.Drawing.Size(67, 23);
this.clipsBookmarksFormatCb.TabIndex = 21;
//
// downloadClipsBookmarksCbox
//
this.downloadClipsBookmarksCbox.AutoSize = true;
this.downloadClipsBookmarksCbox.Location = new System.Drawing.Point(19, 68);
this.downloadClipsBookmarksCbox.Name = "downloadClipsBookmarksCbox";
this.downloadClipsBookmarksCbox.Size = new System.Drawing.Size(248, 19);
this.downloadClipsBookmarksCbox.TabIndex = 20;
this.downloadClipsBookmarksCbox.Text = "Download Clips, Notes, and Bookmarks as";
this.downloadClipsBookmarksCbox.UseVisualStyleBackColor = true;
this.downloadClipsBookmarksCbox.CheckedChanged += new System.EventHandler(this.downloadClipsBookmarksCbox_CheckedChanged);
//
// audiobookFixupsGb
//
this.audiobookFixupsGb.Controls.Add(this.splitFilesByChapterCbox);
@ -656,7 +680,7 @@
this.audiobookFixupsGb.Controls.Add(this.convertLosslessRb);
this.audiobookFixupsGb.Controls.Add(this.convertLossyRb);
this.audiobookFixupsGb.Controls.Add(this.stripAudibleBrandingCbox);
this.audiobookFixupsGb.Location = new System.Drawing.Point(6, 143);
this.audiobookFixupsGb.Location = new System.Drawing.Point(6, 169);
this.audiobookFixupsGb.Name = "audiobookFixupsGb";
this.audiobookFixupsGb.Size = new System.Drawing.Size(403, 160);
this.audiobookFixupsGb.TabIndex = 19;
@ -1032,7 +1056,7 @@
// mergeOpeningEndCreditsCbox
//
this.mergeOpeningEndCreditsCbox.AutoSize = true;
this.mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 93);
this.mergeOpeningEndCreditsCbox.Location = new System.Drawing.Point(19, 118);
this.mergeOpeningEndCreditsCbox.Name = "mergeOpeningEndCreditsCbox";
this.mergeOpeningEndCreditsCbox.Size = new System.Drawing.Size(198, 19);
this.mergeOpeningEndCreditsCbox.TabIndex = 13;
@ -1042,7 +1066,7 @@
// retainAaxFileCbox
//
this.retainAaxFileCbox.AutoSize = true;
this.retainAaxFileCbox.Location = new System.Drawing.Point(19, 68);
this.retainAaxFileCbox.Location = new System.Drawing.Point(19, 93);
this.retainAaxFileCbox.Name = "retainAaxFileCbox";
this.retainAaxFileCbox.Size = new System.Drawing.Size(132, 19);
this.retainAaxFileCbox.TabIndex = 10;
@ -1214,5 +1238,7 @@
private System.Windows.Forms.GroupBox audiobookFixupsGb;
private System.Windows.Forms.CheckBox betaOptInCbox;
private System.Windows.Forms.CheckBox useCoverAsFolderIconCb;
}
private System.Windows.Forms.ComboBox clipsBookmarksFormatCb;
private System.Windows.Forms.CheckBox downloadClipsBookmarksCbox;
}
}

View File

@ -0,0 +1,221 @@
namespace LibationWinForms.Dialogs
{
partial class UpgradeNotificationDialog
{
/// <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()
{
System.Windows.Forms.Label label1;
System.Windows.Forms.Label label2;
System.Windows.Forms.GroupBox groupBox1;
System.Windows.Forms.LinkLabel linkLabel3;
System.Windows.Forms.LinkLabel linkLabel2;
System.Windows.Forms.Label label3;
this.packageDlLink = new System.Windows.Forms.LinkLabel();
this.releaseNotesTbox = new System.Windows.Forms.TextBox();
this.dontRemindBtn = new System.Windows.Forms.Button();
this.yesBtn = new System.Windows.Forms.Button();
this.noBtn = new System.Windows.Forms.Button();
label1 = new System.Windows.Forms.Label();
label2 = new System.Windows.Forms.Label();
groupBox1 = new System.Windows.Forms.GroupBox();
linkLabel3 = new System.Windows.Forms.LinkLabel();
linkLabel2 = new System.Windows.Forms.LinkLabel();
label3 = new System.Windows.Forms.Label();
groupBox1.SuspendLayout();
this.SuspendLayout();
//
// label1
//
label1.AutoSize = true;
label1.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
label1.Location = new System.Drawing.Point(12, 9);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(416, 21);
label1.TabIndex = 0;
label1.Text = "There is a new version available. Would you like to update?";
//
// label2
//
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(12, 39);
label2.Name = "label2";
label2.Padding = new System.Windows.Forms.Padding(0, 0, 0, 10);
label2.Size = new System.Drawing.Size(327, 25);
label2.TabIndex = 1;
label2.Text = "After you close Libation, the upgrade will start automatically.";
//
// groupBox1
//
groupBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
groupBox1.Controls.Add(linkLabel3);
groupBox1.Controls.Add(linkLabel2);
groupBox1.Controls.Add(this.packageDlLink);
groupBox1.Controls.Add(label3);
groupBox1.Controls.Add(this.releaseNotesTbox);
groupBox1.Location = new System.Drawing.Point(12, 67);
groupBox1.Name = "groupBox1";
groupBox1.Size = new System.Drawing.Size(531, 303);
groupBox1.TabIndex = 3;
groupBox1.TabStop = false;
groupBox1.Text = "Release Information";
//
// linkLabel3
//
linkLabel3.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
linkLabel3.AutoSize = true;
linkLabel3.Location = new System.Drawing.Point(348, 250);
linkLabel3.Name = "linkLabel3";
linkLabel3.Padding = new System.Windows.Forms.Padding(0, 0, 0, 10);
linkLabel3.Size = new System.Drawing.Size(177, 25);
linkLabel3.TabIndex = 1;
linkLabel3.TabStop = true;
linkLabel3.Text = "View the source code on GitHub";
linkLabel3.LinkClicked += new System.Windows.Forms.LinkLabelLinkClickedEventHandler(this.GoToGithub_LinkClicked);
//
// linkLabel2
//
linkLabel2.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
linkLabel2.AutoSize = true;
linkLabel2.Location = new System.Drawing.Point(392, 275);
linkLabel2.Name = "linkLabel2";
linkLabel2.Padding = new System.Windows.Forms.Padding(0, 0, 0, 10);
linkLabel2.Size = new System.Drawing.Size(133, 25);
linkLabel2.TabIndex = 2;
linkLabel2.TabStop = true;
linkLabel2.Text = "Go to Libation\'s website";
linkLabel2.LinkClicked += new System.Windows.Forms.LinkLabelLinkClickedEventHandler(this.GoToWebsite_LinkClicked);
//
// packageDlLink
//
this.packageDlLink.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.packageDlLink.AutoSize = true;
this.packageDlLink.Location = new System.Drawing.Point(6, 275);
this.packageDlLink.Name = "packageDlLink";
this.packageDlLink.Padding = new System.Windows.Forms.Padding(0, 0, 0, 10);
this.packageDlLink.Size = new System.Drawing.Size(157, 25);
this.packageDlLink.TabIndex = 3;
this.packageDlLink.TabStop = true;
this.packageDlLink.Text = "[Release Package File Name]";
this.packageDlLink.LinkClicked += new System.Windows.Forms.LinkLabelLinkClickedEventHandler(this.PackageDlLink_LinkClicked);
//
// label3
//
label3.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
label3.AutoSize = true;
label3.Location = new System.Drawing.Point(6, 250);
label3.Name = "label3";
label3.Size = new System.Drawing.Size(106, 15);
label3.TabIndex = 3;
label3.Text = "Download Release:";
//
// releaseNotesTbox
//
this.releaseNotesTbox.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.releaseNotesTbox.Location = new System.Drawing.Point(6, 22);
this.releaseNotesTbox.Multiline = true;
this.releaseNotesTbox.Name = "releaseNotesTbox";
this.releaseNotesTbox.ReadOnly = true;
this.releaseNotesTbox.ScrollBars = System.Windows.Forms.ScrollBars.Vertical;
this.releaseNotesTbox.Size = new System.Drawing.Size(519, 214);
this.releaseNotesTbox.TabIndex = 0;
//
// dontRemindBtn
//
this.dontRemindBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.dontRemindBtn.Location = new System.Drawing.Point(12, 376);
this.dontRemindBtn.Name = "dontRemindBtn";
this.dontRemindBtn.Size = new System.Drawing.Size(121, 38);
this.dontRemindBtn.TabIndex = 4;
this.dontRemindBtn.Text = "Don\'t remind me about this release";
this.dontRemindBtn.UseVisualStyleBackColor = true;
this.dontRemindBtn.Click += new System.EventHandler(this.DontRemindBtn_Click);
//
// yesBtn
//
this.yesBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.yesBtn.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.yesBtn.Location = new System.Drawing.Point(440, 376);
this.yesBtn.Name = "yesBtn";
this.yesBtn.Size = new System.Drawing.Size(103, 38);
this.yesBtn.TabIndex = 6;
this.yesBtn.Text = "Yes";
this.yesBtn.UseVisualStyleBackColor = true;
this.yesBtn.Click += new System.EventHandler(this.YesBtn_Click);
//
// noBtn
//
this.noBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.noBtn.Font = new System.Drawing.Font("Segoe UI", 12F, System.Drawing.FontStyle.Regular, System.Drawing.GraphicsUnit.Point);
this.noBtn.Location = new System.Drawing.Point(360, 376);
this.noBtn.Name = "noBtn";
this.noBtn.Size = new System.Drawing.Size(74, 38);
this.noBtn.TabIndex = 5;
this.noBtn.Text = "No";
this.noBtn.UseVisualStyleBackColor = true;
this.noBtn.Click += new System.EventHandler(this.NoBtn_Click);
//
// UpgradeNotificationDialog
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(555, 426);
this.Controls.Add(this.noBtn);
this.Controls.Add(this.yesBtn);
this.Controls.Add(this.dontRemindBtn);
this.Controls.Add(groupBox1);
this.Controls.Add(label2);
this.Controls.Add(label1);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.MinimumSize = new System.Drawing.Size(460, 420);
this.Name = "UpgradeNotificationDialog";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "UpgradeNotificationDialog";
groupBox1.ResumeLayout(false);
groupBox1.PerformLayout();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label label1;
private System.Windows.Forms.TextBox releaseNotesTbox;
private System.Windows.Forms.GroupBox groupBox1;
private System.Windows.Forms.LinkLabel linkLabel3;
private System.Windows.Forms.LinkLabel linkLabel2;
private System.Windows.Forms.LinkLabel packageDlLink;
private System.Windows.Forms.Button dontRemindBtn;
private System.Windows.Forms.Button yesBtn;
private System.Windows.Forms.Button noBtn;
}
}

View File

@ -0,0 +1,71 @@
using AppScaffolding;
using Dinah.Core;
using LibationFileManager;
using System;
using System.Windows.Forms;
namespace LibationWinForms.Dialogs
{
public partial class UpgradeNotificationDialog : Form
{
private string PackageUrl { get; }
public UpgradeNotificationDialog()
{
InitializeComponent();
this.SetLibationIcon();
}
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties) : this()
{
Text = $"Libation version {upgradeProperties.LatestRelease.ToString(3)} is now available.";
PackageUrl = upgradeProperties.ZipUrl;
packageDlLink.Text = upgradeProperties.ZipName;
releaseNotesTbox.Text = upgradeProperties.Notes;
Shown += (_, _) => yesBtn.Focus();
Load += UpgradeNotificationDialog_Load;
}
private void UpgradeNotificationDialog_Load(object sender, EventArgs e)
{
//This dialog starts before Form1, soposition it at the center of where Form1 will be.
var savedState = Configuration.Instance.GetNonString<FormSizeAndPosition>(nameof(Form1));
if (savedState is null) return;
int x = savedState.X + (savedState.Width - Width) / 2;
int y = savedState.Y + (savedState.Height - Height) / 2;
Location = new(x, y);
TopMost = true;
}
private void PackageDlLink_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
=> Go.To.Url(PackageUrl);
private void GoToGithub_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
=> Go.To.Url(LibationScaffolding.RepositoryUrl);
private void GoToWebsite_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
=> Go.To.Url(LibationScaffolding.WebsiteUrl);
private void YesBtn_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Yes;
Close();
}
private void DontRemindBtn_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.Ignore;
Close();
}
private void NoBtn_Click(object sender, EventArgs e)
{
DialogResult = DialogResult.No;
Close();
}
}
}

View File

@ -0,0 +1,78 @@
<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>
<metadata name="label1.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
<metadata name="label2.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
<metadata name="groupBox1.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
<metadata name="linkLabel3.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
<metadata name="linkLabel2.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
<metadata name="label3.GenerateMember" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>False</value>
</metadata>
</root>

View File

@ -78,7 +78,7 @@ namespace LibationWinForms
private void ToggleQueueHideBtn_Click(object sender, EventArgs e)
{
SetQueueCollapseState(!splitContainer1.Panel2Collapsed);
Configuration.Instance.SetObject(nameof(splitContainer1.Panel2Collapsed), splitContainer1.Panel2Collapsed);
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
}
private void ProcessBookQueue1_PopOut(object sender, EventArgs e)

View File

@ -56,12 +56,19 @@ namespace LibationWinForms
AccountsSettingsPersister.Saving += accountsPreSave;
AccountsSettingsPersister.Saved += accountsPostSave;
// when autoscan setting is changed, update menu checkbox and run autoscan
Configuration.Instance.AutoScanChanged += updateAutoScanLibraryToolStripMenuItem;
Configuration.Instance.AutoScanChanged += startAutoScan;
Configuration.Instance.PropertyChanged += Configuration_PropertyChanged;
}
private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts;
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
private void Configuration_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
{
// when autoscan setting is changed, update menu checkbox and run autoscan
updateAutoScanLibraryToolStripMenuItem(sender, e);
startAutoScan(sender, e);
}
private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts;
private List<(string AccountId, string LocaleName)> getDefaultAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();

View File

@ -87,11 +87,10 @@ namespace LibationWinForms
saveState.IsMaximized = form.WindowState == FormWindowState.Maximized;
config.SetObject(form.Name, saveState);
config.SetNonString(saveState, form.Name);
}
}
class FormSizeAndPosition
record FormSizeAndPosition
{
public int X;
public int Y;

View File

@ -71,6 +71,15 @@ namespace LibationWinForms.GridView
&& updateReviewTask?.IsCompleted is not false)
{
updateReviewTask = UpdateRating(value);
updateReviewTask.ContinueWith(t =>
{
if (t.Result)
{
_myRating = value;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, value);
}
NotifyPropertyChanged();
});
}
}
}
@ -80,18 +89,12 @@ namespace LibationWinForms.GridView
#region User rating
private Task updateReviewTask;
private async Task UpdateRating(Rating rating)
private Task<bool> updateReviewTask;
private async Task<bool> UpdateRating(Rating rating)
{
var api = await LibraryBook.GetApiAsync();
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
{
_myRating = rating;
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
}
this.NotifyPropertyChanged(nameof(MyRating));
return await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating);
}
#endregion

View File

@ -8,6 +8,7 @@ using ApplicationServices;
using DataLayer;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms.GridView
{
@ -138,22 +139,22 @@ namespace LibationWinForms.GridView
var setDownloadMenuItem = new ToolStripMenuItem()
{
Text = "Set Download status to 'Downloaded'",
Text = "Set Download status to '&Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
};
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
var setNotDownloadMenuItem = new ToolStripMenuItem()
{
Text = "Set Download status to 'Not Downloaded'",
Text = "Set Download status to '&Not Downloaded'",
Enabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
};
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var removeMenuItem = new ToolStripMenuItem() { Text = "Remove from library" };
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId);
var locateFileMenuItem = new ToolStripMenuItem() { Text = "Locate file..." };
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
locateFileMenuItem.Click += (_, __) =>
{
try
@ -174,13 +175,18 @@ namespace LibationWinForms.GridView
}
};
var stopLightContextMenu = new ContextMenuStrip();
var bookRecordMenuItem = new ToolStripMenuItem { Text = "View &Bookmarks/Clips" };
bookRecordMenuItem.Click += (_, _) => new BookRecordsDialog(entry.LibraryBook).ShowDialog(this);
var stopLightContextMenu = new ContextMenuStrip();
stopLightContextMenu.Items.Add(setDownloadMenuItem);
stopLightContextMenu.Items.Add(setNotDownloadMenuItem);
stopLightContextMenu.Items.Add(removeMenuItem);
stopLightContextMenu.Items.Add(locateFileMenuItem);
stopLightContextMenu.Items.Add(new ToolStripSeparator());
stopLightContextMenu.Items.Add(bookRecordMenuItem);
e.ContextMenuStrip = stopLightContextMenu;
e.ContextMenuStrip = stopLightContextMenu;
}
private GridEntry getGridEntry(int rowIndex) => gridEntryDataGridView.GetBoundItem<GridEntry>(rowIndex);

View File

@ -188,7 +188,7 @@ namespace LibationWinForms
return;
}
Updater.Run(upgradeProperties.LatestRelease, upgradeProperties.ZipUrl);
Updater.Run(upgradeProperties);
}
private static void postLoggingGlobalExceptionHandling()

View File

@ -1,50 +1,57 @@
using System;
using System.Windows.Forms;
using AppScaffolding;
using AutoUpdaterDotNET;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms
{
public static class Updater
{
private const string REPO_URL = "https://github.com/rmcrackan/Libation/releases/latest";
public static void Run(Version latestVersionOnServer, string downloadZipUrl)
=> Run(latestVersionOnServer.ToString(), downloadZipUrl);
public static void Run(string latestVersionOnServer, string downloadZipUrl)
public static void Run(UpgradeProperties upgradeProperties)
{
string latestVersionOnServer = upgradeProperties.LatestRelease.ToString();
string downloadZipUrl = upgradeProperties.ZipUrl;
AutoUpdater.ParseUpdateInfoEvent +=
args => args.UpdateInfo = new()
{
CurrentVersion = latestVersionOnServer,
DownloadURL = downloadZipUrl,
ChangelogURL = REPO_URL
ChangelogURL = LibationScaffolding.RepositoryLatestUrl
};
void AutoUpdaterOnCheckForUpdateEvent(UpdateInfoEventArgs args)
{
if (args is null || !args.IsUpdateAvailable)
return;
const string ignoreUpdate = "IgnoreUpdate";
var config = Configuration.Instance;
if (config.GetString(ignoreUpdate) == args.CurrentVersion)
return;
var notificationResult = new UpgradeNotificationDialog(upgradeProperties).ShowDialog();
if (notificationResult == DialogResult.Ignore)
config.SetString(upgradeProperties.LatestRelease.ToString(), ignoreUpdate);
if (notificationResult != DialogResult.Yes) return;
try
{
Serilog.Log.Logger.Information("Start upgrade. {@DebugInfo}", new { CurrentlyInstalled = args.InstalledVersion, TargetVersion = args.CurrentVersion });
AutoUpdater.DownloadUpdate(args);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(null, "Error downloading update", "Error downloading update", ex);
}
}
AutoUpdater.CheckForUpdateEvent += AutoUpdaterOnCheckForUpdateEvent;
AutoUpdater.Start(REPO_URL);
}
private static void AutoUpdaterOnCheckForUpdateEvent(UpdateInfoEventArgs args)
{
if (args is null || !args.IsUpdateAvailable)
return;
var dialogResult = MessageBox.Show(string.Format(
$"There is a new version available. Would you like to update?\r\n\r\nAfter you close Libation, the upgrade will start automatically."),
"Update Available",
MessageBoxButtons.YesNo,
MessageBoxIcon.Information);
if (dialogResult != DialogResult.Yes)
return;
try
{
Serilog.Log.Logger.Information("Start upgrade. {@DebugInfo}", new { CurrentlyInstalled = args.InstalledVersion, TargetVersion = args.CurrentVersion });
AutoUpdater.DownloadUpdate(args);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(null, "Error downloading update", "Error downloading update", ex);
}
AutoUpdater.Start(LibationScaffolding.RepositoryLatestUrl);
}
}
}