mirror of
https://github.com/rmcrackan/Libation.git
synced 2025-12-30 17:38:14 -05:00
Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54d157d244 | ||
|
|
a4dfdf80e4 | ||
|
|
d8c90bc745 | ||
|
|
46accddd2d | ||
|
|
f40ecbc07e | ||
|
|
536982cb5f | ||
|
|
ea3d96329b | ||
|
|
e87fcbb16f | ||
|
|
541cf79b6f | ||
|
|
55fa82f92e | ||
|
|
4a0c2b2180 | ||
|
|
c77fe5d561 | ||
|
|
359d082ffd | ||
|
|
017bdba404 | ||
|
|
d4bf13b3fd | ||
|
|
87b695b2de | ||
|
|
222b16113e | ||
|
|
75c07c3209 | ||
|
|
e640edee7f | ||
|
|
6c48fc1f5e | ||
|
|
e5708a382b |
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -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>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
153
Source/LibationSearchEngine/QuerySanitizer.cs
Normal file
153
Source/LibationSearchEngine/QuerySanitizer.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user