Compare commits

...

21 Commits

Author SHA1 Message Date
Robert McRackan
54d157d244 another tag fail. incr ver 2023-06-11 17:07:03 -04:00
Robert McRackan
a4dfdf80e4 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-06-11 17:03:55 -04:00
Robert McRackan
d8c90bc745 incr ver 2023-06-11 17:03:35 -04:00
Mbucari
46accddd2d Merge pull request #623 from Mbucari/master
Redesign query sanitizer (#618)
2023-06-11 11:51:13 -06:00
Mbucari
f40ecbc07e Merge branch 'rmcrackan:master' into master 2023-06-11 11:33:28 -06:00
Mbucari
536982cb5f Remove obsolete code 2023-06-11 09:44:30 -06:00
Mbucari
ea3d96329b Add query sanitization unit tests 2023-06-11 09:44:21 -06:00
rmcrackan
e87fcbb16f Update Settings documentation 2023-06-11 10:04:00 -04:00
Mbucari
541cf79b6f Redesign query sanitizer (#618) 2023-06-10 15:08:50 -06:00
Robert McRackan
55fa82f92e New incr ver. Previous Tag attempt did generate builds; did not draft a new release 2023-06-09 11:49:59 -04:00
Robert McRackan
4a0c2b2180 Bug fix #618 2023-06-09 11:27:40 -04:00
Mbucari
c77fe5d561 Add Asin query tokenizer 2023-06-08 14:23:39 -06:00
Robert McRackan
359d082ffd incr ver 2023-06-03 15:06:12 -04:00
rmcrackan
017bdba404 Merge pull request #616 from Mbucari/master
Fix #612 and update Avalonia to v11-rc1
2023-06-03 15:04:56 -04:00
Mbucari
d4bf13b3fd Update Hangover Avalonia to v11-rc1 2023-06-03 00:30:02 -06:00
Mbucari
87b695b2de Merge branch 'rmcrackan:master' into master 2023-06-03 00:01:10 -06:00
Mbucari
222b16113e Update NamingTemplates.md 2023-06-03 00:00:01 -06:00
Mbucari
75c07c3209 Fix SavePodcastsToParentFolder setting (#612) 2023-06-02 23:54:32 -06:00
Mbucari
e640edee7f Use proper key name 2023-06-02 23:53:48 -06:00
Mbucari
6c48fc1f5e Update avalonia ro v11-RC1 2023-06-02 23:39:16 -06:00
Mbucari
e5708a382b Use new synchronous UI invoker 2023-06-02 23:21:55 -06:00
23 changed files with 273 additions and 303 deletions

View File

@@ -28,6 +28,15 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
* Adds the `TCOM` metadata tag for the narrators.
* Sets the `©gen` metadata tag for the genres.
* Unescapes the copyright symbol (replace `©` with `©`)
* Replaces the recording copyright `(P)` string with `℗`
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.

View File

@@ -63,6 +63,9 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.

View File

@@ -190,7 +190,11 @@ namespace AaxDecrypter
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
else
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
OnFileCreated(aaxPath);
OnFileCreated(keyPath);

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>10.3.2.1</Version>
<Version>10.3.7.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="6.0.0" />

View File

@@ -25,8 +25,7 @@ namespace FileLiberator
if (seriesParent is not null)
{
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
}
}
}

View File

@@ -41,7 +41,8 @@ namespace FileLiberator
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
IsPodcast = libraryBook.Book.IsEpisodeChild(),
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,

View File

@@ -2,6 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
@@ -66,13 +67,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc1.1" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview8" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -28,8 +28,6 @@ namespace LibationAvalonia
public static IBrush ProcessQueueBookDefaultBrush { get; private set; }
public static IBrush SeriesEntryGridBackgroundBrush { get; private set; }
public static IAssetLoader AssetLoader { get; private set; }
public static readonly Uri AssetUriBase = new("avares://Libation/Assets/");
public static Stream OpenAsset(string assetRelativePath)
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
@@ -37,7 +35,6 @@ namespace LibationAvalonia
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
AssetLoader = AvaloniaLocator.Current.GetService<IAssetLoader>();
}
public static Task<List<DataLayer.LibraryBook>> LibraryTask;

View File

@@ -10,9 +10,9 @@ using System.Windows.Input;
namespace LibationAvalonia.Controls
{
public partial class LinkLabel : TextBlock, IStyleable, ICommandSource
public partial class LinkLabel : TextBlock, ICommandSource
{
Type IStyleable.StyleKey => typeof(LinkLabel);
protected override Type StyleKeyOverride => typeof(LinkLabel);
public static readonly StyledProperty<ICommand> CommandProperty =
AvaloniaProperty.Register<LinkLabel, ICommand>(nameof(Command), enableDataValidation: true);

View File

@@ -1,13 +1,13 @@
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Styling;
using System;
namespace LibationAvalonia.Controls
{
public partial class WheelComboBox : ComboBox, IStyleable
public partial class WheelComboBox : ComboBox
{
Type IStyleable.StyleKey => typeof(ComboBox);
protected override Type StyleKeyOverride => typeof(ComboBox);
public WheelComboBox()
{
InitializeComponent();
@@ -16,9 +16,15 @@ namespace LibationAvalonia.Controls
{
var dir = Math.Sign(e.Delta.Y);
if (dir == 1 && SelectedIndex > 0)
{
SelectedIndex--;
e.Handled = true;
}
else if (dir == -1 && SelectedIndex < ItemCount - 1)
{
SelectedIndex++;
e.Handled = true;
}
base.OnPointerWheelChanged(e);
}

View File

@@ -70,13 +70,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-preview8" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview8" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-rc1.1" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc1.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,44 +0,0 @@
using Avalonia;
using Avalonia.Input;
namespace LibationAvalonia
{
internal class MacAccessKeyHandler : AccessKeyHandler
{
protected override void OnPreviewKeyDown(object sender, KeyEventArgs e)
{
if (e.Key is Key.LWin or Key.RWin)
{
var newArgs = new KeyEventArgs { Key = Key.LeftAlt, Handled = e.Handled };
base.OnPreviewKeyDown(sender, newArgs);
e.Handled = newArgs.Handled;
}
else if (e.Key is not Key.LeftAlt and not Key.RightAlt)
base.OnPreviewKeyDown(sender, e);
}
protected override void OnPreviewKeyUp(object sender, KeyEventArgs e)
{
if (e.Key is Key.LWin or Key.RWin)
{
var newArgs = new KeyEventArgs { Key = Key.LeftAlt, Handled = e.Handled };
base.OnPreviewKeyUp(sender, newArgs);
e.Handled = newArgs.Handled;
}
else if (e.Key is not Key.LeftAlt and not Key.RightAlt)
base.OnPreviewKeyDown(sender, e);
}
protected override void OnKeyDown(object sender, KeyEventArgs e)
{
if (e.KeyModifiers.HasAllFlags(KeyModifiers.Meta))
{
var newArgs = new KeyEventArgs { Key = e.Key, Handled = e.Handled, KeyModifiers = KeyModifiers.Alt };
base.OnKeyDown(sender, newArgs);
e.Handled = newArgs.Handled;
}
else if (!e.KeyModifiers.HasFlag(KeyModifiers.Alt))
base.OnPreviewKeyDown(sender, e);
}
}
}

View File

@@ -61,12 +61,12 @@ namespace LibationAvalonia.ViewModels
#region Properties exposed to the view
public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } }
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string Author { get => _author; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string Title { get => _title; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public int Progress { get => _progress; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
public string ETA { get => _eta; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
public string ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;

View File

@@ -45,11 +45,11 @@ namespace LibationAvalonia.ViewModels
private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
public bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
@@ -79,7 +79,7 @@ namespace LibationAvalonia.ViewModels
: _speedLimit > 1 ? 0.1m
: 0.01m;
Dispatcher.UIThread.Post(() =>
Dispatcher.UIThread.Invoke(() =>
{
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
@@ -106,7 +106,7 @@ namespace LibationAvalonia.ViewModels
public void WriteLine(string text)
{
Dispatcher.UIThread.Post(() =>
Dispatcher.UIThread.Invoke(() =>
LogEntries.Add(new()
{
LogDate = DateTime.Now,
@@ -183,7 +183,7 @@ namespace LibationAvalonia.ViewModels
public void AddToQueue(IEnumerable<ProcessBookViewModel> pbook)
{
Dispatcher.UIThread.Post(() =>
Dispatcher.UIThread.Invoke(() =>
{
Queue.Enqueue(pbook);
if (!Running)

View File

@@ -252,8 +252,8 @@ namespace LibationAvalonia.Views
var displayIndices = config.GridColumnsDisplayIndices;
var contextMenu = new ContextMenu();
contextMenu.MenuClosed += ContextMenu_MenuClosed;
contextMenu.ContextMenuOpening += ContextMenu_ContextMenuOpening;
contextMenu.Closed += ContextMenu_MenuClosed;
contextMenu.Opening += ContextMenu_ContextMenuOpening;
List<Control> menuItems = new();
contextMenu.ItemsSource = menuItems;

View File

@@ -248,7 +248,7 @@ namespace LibationAvalonia
private async Task displayControlAsync(TemplatedControl control)
{
await UIThread.InvokeAsync(() => control.IsEnabled = false);
await UIThread.InvokeAsync(MainForm.productsDisplay.Focus);
await UIThread.InvokeAsync(() => MainForm.productsDisplay.Focus());
await UIThread.InvokeAsync(() => flashControlAsync(control));
if (control is MenuItem menuItem) await UIThread.InvokeAsync(menuItem.Open);
await Task.Delay(500);

View File

@@ -22,6 +22,7 @@ namespace LibationFileManager
public string SeriesName { get; set; }
public int? SeriesNumber { get; set; }
public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
public bool IsPodcastParent { get; set; }
public bool IsPodcast { get; set; }
public int BitRate { get; set; }

View File

@@ -47,6 +47,7 @@ namespace LibationFileManager
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<if podcastparent-><-if podcastparent>", "<if podcastparent->...<-if podcastparent>");
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
}
}

View File

@@ -207,13 +207,13 @@ namespace LibationFileManager
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter },
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator },
{ TemplateTags.Series, lb => lb.SeriesName },
{ TemplateTags.SeriesNumber, lb => lb.SeriesNumber },
{ TemplateTags.SeriesNumber, lb => lb.IsPodcastParent ? null : lb.SeriesNumber },
{ TemplateTags.Language, lb => lb.Language },
//Don't allow formatting of LanguageShort
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
{ TemplateTags.Bitrate, lb => lb.BitRate },
{ TemplateTags.SampleRate, lb => lb.SampleRate },
{ TemplateTags.Channels, lb => lb.Channels },
{ TemplateTags.Bitrate, lb => (int?)(lb.IsPodcastParent ? null : lb.BitRate) },
{ TemplateTags.SampleRate, lb => (int?)(lb.IsPodcastParent ? null : lb.SampleRate) },
{ TemplateTags.Channels, lb => (int?)(lb.IsPodcastParent ? null : lb.Channels) },
{ TemplateTags.Account, lb => lb.Account },
{ TemplateTags.Locale, lb => lb.Locale },
{ TemplateTags.YearPublished, lb => lb.YearPublished },
@@ -242,9 +242,14 @@ namespace LibationFileManager
private static readonly ConditionalTagCollection<LibraryBookDto> conditionalTags = new()
{
{ TemplateTags.IfSeries, lb => lb.IsSeries },
{ TemplateTags.IfPodcast, lb => lb.IsPodcast },
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast },
{ TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent },
{ TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent },
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent },
};
private static readonly ConditionalTagCollection<LibraryBookDto> folderConditionalTags = new()
{
{ TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent }
};
#endregion
@@ -293,7 +298,8 @@ namespace LibationFileManager
public static string Name { get; }= "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections => new TagCollection[] { filePropertyTags, conditionalTags };
public static IEnumerable<TagCollection> TagCollections
=> new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags };
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;

View File

@@ -1,103 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace LibationSearchEngine
{
internal static partial class LuceneRegex
{
#region pattern pieces
// negative lookbehind: cannot be preceeded by an escaping \
const string NOT_ESCAPED = @"(?<!\\)";
// disallow spaces and lucene reserved characters
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
// define chars
// escape and concat
// create regex. also disallow spaces
private static char[] disallowedChars { get; } = new[] {
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\' };
private static string disallowedCharsEscaped { get; } = disallowedChars.Select(c => $@"\{c}").Aggregate((a, b) => a + b);
private static string WORD_CAPTURE { get; } = $@"([^\s{disallowedCharsEscaped}]+)";
// : with optional preceeding spaces. capture these so i don't accidentally replace a non-field name
const string FIELD_END = @"(\s*:)";
const string BEGIN_TAG = @"\[";
const string END_TAG = @"\]";
// space is forgiven at beginning and end of tag but not in the middle
// literal space character only. do NOT allow new lines, tabs, ...
const string OPTIONAL_SPACE_LITERAL = @"\u0020*";
#endregion
private static string tagPattern { get; } = NOT_ESCAPED + BEGIN_TAG + OPTIONAL_SPACE_LITERAL + WORD_CAPTURE + OPTIONAL_SPACE_LITERAL + END_TAG;
public static Regex TagRegex { get; } = new Regex(tagPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
private static string fieldPattern { get; } = NOT_ESCAPED + WORD_CAPTURE + FIELD_END;
public static Regex FieldRegex { get; } = new Regex(fieldPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
/// <summary>
/// auto-pad numbers to 8 char.s. This will match int.s and dates (yyyyMMdd)
/// positive look behind: beginning space { [ :
/// positive look ahead: end space ] }
/// </summary>
[GeneratedRegex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", RegexOptions.Compiled)]
public static partial Regex NumbersRegex();
/// <summary>
/// proper bools are single keywords which are turned into keyword:True
/// if bordered by colons or inside brackets, they are not stand-alone bool keywords
/// the negative lookbehind and lookahead patterns prevent bugs where a bool keyword is also a user-defined tag:
/// [israted]
/// parseTag => tags:israted
/// replaceBools => tags:israted:True
/// or
/// [israted]
/// replaceBools => israted:True
/// parseTag => [israted:True]
/// also don't want to apply :True where the value already exists:
/// israted:false => israted:false:True
///
/// despite using parans, lookahead and lookbehind are zero-length assertions which do not capture. therefore the bool search keyword is still $1 since it's the first and only capture
/// </summary>
private static string boolPattern_parameterized { get; }
= @"
### IMPORTANT: 'ignore whitespace' is only partially honored in character sets
### - new lines are ok
### - ANY leading whitespace is treated like actual matching spaces :(
### can't begin with colon. incorrect syntax
### can't begin with open bracket: this signals the start of a tag
(?<! # begin negative lookbehind
[:\[] # char set: colon and open bracket, escaped
\s* # optional space
) # end negative lookbehind
\b # word boundary
({0}) # captured bool search keyword. this is the $1 reference used in regex.Replace
\b # word boundary
### can't end with colon. this signals that the bool's value already exists
### can't begin with close bracket: this signals the end of a tag
(?! # begin negative lookahead
\s* # optional space
[:\]] # char set: colon and close bracket, escaped
) # end negative lookahead
";
private static Dictionary<string, Regex> boolRegexDic { get; } = new Dictionary<string, Regex>();
public static Regex GetBoolRegex(string boolSearch)
{
if (boolRegexDic.TryGetValue(boolSearch, out var regex))
return regex;
var boolPattern = string.Format(boolPattern_parameterized, boolSearch);
regex = new Regex(boolPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.IgnoreCase | RegexOptions.Compiled);
boolRegexDic.Add(boolSearch, regex);
return regex;
}
}
}

View File

@@ -0,0 +1,153 @@
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Analysis.Tokenattributes;
using System.Collections.Generic;
using System.Linq;
namespace LibationSearchEngine
{
internal static class QuerySanitizer
{
private static readonly HashSet<string> idTerms
= SearchEngine.idIndexRules.Keys
.Select(s => s.ToLowerInvariant())
.ToHashSet();
private static readonly HashSet<string> boolTerms
= SearchEngine.boolIndexRules.Keys
.Select(s => s.ToLowerInvariant())
.ToHashSet();
private static readonly HashSet<string> fieldTerms
= SearchEngine.stringIndexRules.Keys
.Union(SearchEngine.numberIndexRules.Keys)
.Select(s => s.ToLowerInvariant())
.Union(idTerms)
.Union(boolTerms)
.ToHashSet();
internal static string Sanitize(string searchString, StandardAnalyzer analyzer)
{
if (string.IsNullOrWhiteSpace(searchString))
return SearchEngine.ALL_QUERY;
// range operator " TO " and bool operators " AND " and " OR " must be uppercase
searchString
= searchString
.Replace(" to ", " TO ", System.StringComparison.OrdinalIgnoreCase)
.Replace(" and ", " AND ", System.StringComparison.OrdinalIgnoreCase)
.Replace(" or ", " OR ", System.StringComparison.OrdinalIgnoreCase);
using var tokenStream = analyzer.TokenStream(SearchEngine.ALL, new System.IO.StringReader(searchString));
var partList = new List<string>();
int previousEndOffset = 0;
bool previousIsBool = false, previousIsTags = false, previousIsAsin = false;
while (tokenStream.IncrementToken())
{
var term = tokenStream.GetAttribute<ITermAttribute>().Term;
var offset = tokenStream.GetAttribute<IOffsetAttribute>();
if (previousIsBool && !bool.TryParse(term, out _))
{
//The previous term was a boolean tag and this term is NOT a bool value
//Add the default ":True" bool and continue parsing the current term
partList.Add(":True");
previousIsBool = false;
}
//Add all text between the current token and the previous token
partList.Add(searchString.Substring(previousEndOffset, offset.StartOffset - previousEndOffset));
if (previousIsBool)
{
//The previous term was a boolean tag and this term is a bool value
addUnalteredToken(offset);
previousIsBool = false;
}
else if (previousIsAsin)
{
//The previous term was an ASIN field ID, so this term is an ASIN
partList.Add(term);
previousIsAsin = false;
}
else if (previousIsTags)
{
//This term is a tag. Do this check before checking if term is a defined field
//so that "tags:israted" does not parse as a bool
addUnalteredToken(offset);
previousIsTags = false;
}
else if (tryParseBlockTag(offset, partList, searchString, out var tagName))
{
//The term is a block tag. add it to the part list
partList.Add($"{SearchEngine.TAGS}:{tagName}");
}
else if (double.TryParse(term, out var num))
{
//Term is a number so pad it with zeros
partList.Add(num.ToLuceneString());
}
else if (fieldTerms.Contains(term))
{
//Term is a defined search field, add it.
//The StandardAnalyzer already converts all terms to lowercase
partList.Add(term);
previousIsBool = boolTerms.Contains(term);
previousIsAsin = idTerms.Contains(term);
previousIsTags = term == SearchEngine.TAGS;
}
else
{
//Term is any other user-defined constant value
addUnalteredToken(offset);
}
previousEndOffset = offset.EndOffset;
}
if (previousIsBool)
partList.Add(":True");
//Add ending non-token text
partList.Add(searchString.Substring(previousEndOffset, searchString.Length - previousEndOffset));
return string.Concat(partList);
//Add the full, unaltered token as well as all inter-token text
void addUnalteredToken(IOffsetAttribute offset) =>
partList.Add(searchString.Substring(offset.StartOffset, offset.EndOffset - offset.StartOffset));
}
private static bool tryParseBlockTag(IOffsetAttribute offset, List<string> partList, string searchString, out string tagName)
{
tagName = null;
if (partList.Count == 0) return false;
var previous = partList[^1].TrimEnd();
//cannot be preceeded by an escaping \
if (previous.Length == 0) return false;
if (previous[^1] != '[' || (previous.Length > 1 && previous[^2] == '\\')) return false;
var next = searchString.Substring(offset.EndOffset);
if (next.Length == 0 || !next.TrimStart().StartsWith(']')) return false;
tagName = searchString.Substring(offset.StartOffset, offset.EndOffset - offset.StartOffset);
//Only legal tag characters are letters, numbers and underscores
//Per DataLayer.UserDefinedItem.IllegalCharacterRegex()
foreach (var c in tagName)
{
if (!char.IsLetterOrDigit(c) && c != '_')
return false;
}
//Remove the leading '['
partList[^1] = previous[..^1];
//Ignore the trailing ']'
offset.SetOffset(offset.StartOffset, searchString.IndexOf(']', offset.EndOffset) + 1);
return true;
}
}
}

View File

@@ -7,6 +7,7 @@ using DataLayer;
using Dinah.Core;
using LibationFileManager;
using Lucene.Net.Analysis.Standard;
using Lucene.Net.Analysis.Tokenattributes;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Search;
@@ -31,18 +32,18 @@ namespace LibationSearchEngine
public const string ALL_NARRATOR_NAMES = "NarratorNames";
public const string ALL_SERIES_NAMES = "SeriesNames";
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
internal static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
new Dictionary<string, Func<LibraryBook, string>>
{
[nameof(Book.AudibleProductId)] = lb => lb.Book.AudibleProductId,
["ProductId"] = lb => lb.Book.AudibleProductId,
["Id"] = lb => lb.Book.AudibleProductId,
["ASIN"] = lb => lb.Book.AudibleProductId
}
[nameof(Book.AudibleProductId)] = lb => lb.Book.AudibleProductId.ToLowerInvariant(),
["ProductId"] = lb => lb.Book.AudibleProductId.ToLowerInvariant(),
["Id"] = lb => lb.Book.AudibleProductId.ToLowerInvariant(),
["ASIN"] = lb => lb.Book.AudibleProductId.ToLowerInvariant()
}
);
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> stringIndexRules { get; }
internal static ReadOnlyDictionary<string, Func<LibraryBook, string>> stringIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
new Dictionary<string, Func<LibraryBook, string>>
{
@@ -74,7 +75,7 @@ namespace LibationSearchEngine
}
);
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> numberIndexRules { get; }
internal static ReadOnlyDictionary<string, Func<LibraryBook, string>> numberIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
new Dictionary<string, Func<LibraryBook, string>>
{
@@ -98,7 +99,7 @@ namespace LibationSearchEngine
}
);
private static ReadOnlyDictionary<string, Func<LibraryBook, bool>> boolIndexRules { get; }
internal static ReadOnlyDictionary<string, Func<LibraryBook, bool>> boolIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, bool>>(
new Dictionary<string, Func<LibraryBook, bool>>
{
@@ -352,115 +353,34 @@ namespace LibationSearchEngine
#region search
public SearchResultSet Search(string searchString)
{
Serilog.Log.Logger.Debug("original search string: {@DebugInfo}", new { searchString });
searchString = FormatSearchQuery(searchString);
{
using var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
Serilog.Log.Logger.Debug("original search string: {@DebugInfo}", new { searchString });
searchString = QuerySanitizer.Sanitize(searchString, analyzer);
Serilog.Log.Logger.Debug("formatted search string: {@DebugInfo}", new { searchString });
var results = generalSearch(searchString);
var results = generalSearch(searchString, analyzer);
Serilog.Log.Logger.Debug("Hit(s): {@DebugInfo}", new { count = results.Docs.Count() });
displayResults(results);
return results;
}
internal static string FormatSearchQuery(string searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
return ALL_QUERY;
searchString = replaceBools(searchString);
searchString = parseTag(searchString);
// in ranges " TO " must be uppercase
searchString = searchString.Replace(" to ", " TO ");
searchString = padNumbers(searchString);
searchString = lowerFieldNames(searchString);
return searchString;
}
#region format query string
private static string parseTag(string tagSearchString)
{
var allMatches = LuceneRegex
.TagRegex
.Matches(tagSearchString)
.Cast<Match>()
.Select(a => a.ToString())
.ToList();
foreach (var match in allMatches)
tagSearchString = tagSearchString.Replace(
match,
TAGS + ":" + match.Trim('[', ']').Trim()
);
return tagSearchString;
}
private static string replaceBools(string searchString)
{
foreach (var boolSearch in boolIndexRules.Keys)
searchString =
LuceneRegex.GetBoolRegex(boolSearch)
.Replace(searchString, @"$1:True");
return searchString;
}
private static string padNumbers(string searchString)
{
var matches = LuceneRegex
.NumbersRegex()
.Matches(searchString)
.Cast<Match>()
.OrderByDescending(m => m.Index);
foreach (var m in matches)
{
var replaceString = double.Parse(m.ToString()).ToLuceneString();
searchString = LuceneRegex.NumbersRegex().Replace(searchString, replaceString, 1, m.Index);
}
return searchString;
}
private static string lowerFieldNames(string searchString)
{
// fields are case specific
var allMatches = LuceneRegex
.FieldRegex
.Matches(searchString)
.Cast<Match>()
.Select(a => a.ToString())
.ToList();
foreach (var match in allMatches)
searchString = searchString.Replace(match, match.ToLowerInvariant());
return searchString;
}
#endregion
private SearchResultSet generalSearch(string searchString)
private SearchResultSet generalSearch(string searchString, StandardAnalyzer analyzer)
{
var defaultField = ALL;
using var index = getIndex();
using var searcher = new IndexSearcher(index);
using var analyzer = new StandardAnalyzer(Version);
var query = analyzer.GetQuery(defaultField, searchString);
var query = analyzer.GetQuery(defaultField, searchString);
// lucene doesn't allow only negations. eg this returns nothing:
// -tags:hidden
// work arounds: https://kb.ucla.edu/articles/pure-negation-query-in-lucene
// HOWEVER, doing this to any other type of query can cause EVERYTHING to be a match unless "Occur" is carefully set
// this should really check that all leaf nodes are MUST_NOT
if (query is BooleanQuery boolQuery)
// lucene doesn't allow only negations. eg this returns nothing:
// -tags:hidden
// work arounds: https://kb.ucla.edu/articles/pure-negation-query-in-lucene
// HOWEVER, doing this to any other type of query can cause EVERYTHING to be a match unless "Occur" is carefully set
// this should really check that all leaf nodes are MUST_NOT
if (query is BooleanQuery boolQuery)
{
var occurs = getOccurs_recurs(boolQuery);
if (occurs.Any() && occurs.All(o => o == Occur.MUST_NOT))

View File

@@ -10,6 +10,7 @@ using Dinah.Core;
using FluentAssertions;
using FluentAssertions.Common;
using LibationSearchEngine;
using Lucene.Net.Analysis.Standard;
using Microsoft.VisualStudio.TestPlatform.Common.Filtering;
using Microsoft.VisualStudio.TestTools.UnitTesting;
using Moq;
@@ -31,6 +32,7 @@ namespace SearchEngineTests
// tag surrounded by spaces
[DataRow("[foo]", "tags:foo")]
[DataRow(" [foo]", " tags:foo")]
[DataRow(" [ foo ]", " tags:foo")]
[DataRow("[foo] ", "tags:foo ")]
[DataRow(" [foo] ", " tags:foo ")]
[DataRow("-[foo]", "-tags:foo")]
@@ -51,15 +53,25 @@ namespace SearchEngineTests
[DataRow("-israted ", "-israted:True ")]
[DataRow(" -israted ", " -israted:True ")]
//ID Tags to lowercase and not parsed as numbers
[DataRow("id:0000000123", "id:0000000123")]
[DataRow("id:B000000123", "id:b000000123")]
[DataRow("ASIN:B000000123", "asin:b000000123")]
[DataRow("AudibleProductId:B000000123", "audibleproductid:b000000123")]
[DataRow("ProductId:B000000123", "productid:b000000123")]
// bool keyword. Append :True
[DataRow("israted", "israted:True")]
// bool keyword with [:bool]. Do not add :True
[DataRow("israted:True", "israted:True")]
[DataRow("isRated:false", "israted:false")]
[DataRow("liberated AND isRated:false", "liberated:True AND israted:false")]
// tag which happens to be a bool keyword >> parse as tag
[DataRow("[israted]", "tags:israted")]
[DataRow("[tags] [israted] [tags] [tags] [isliberated] [israted] ", "tags:tags tags:israted tags:tags tags:tags tags:isliberated tags:israted ")]
[DataRow("[tags][israted]", "tags:tagstags:israted")]
// numbers with "to". TO all caps, numbers [8.2] format
[DataRow("1 to 10", "00000001.00 TO 00000010.00")]
@@ -72,6 +84,10 @@ namespace SearchEngineTests
[DataRow("-isRATED", "-israted:True")]
public void FormattingTest(string input, string output)
=> SearchEngine.FormatSearchQuery(input).Should().Be(output);
{
using var analyzer = new StandardAnalyzer(SearchEngine.Version);
QuerySanitizer.Sanitize(input, analyzer).Should().Be(output);
}
}
}