Compare commits

...

14 Commits

Author SHA1 Message Date
Robert McRackan
a47866b6f7 Open file/folder is now cross platform 2022-08-02 12:56:52 -04:00
Robert McRackan
0df4dfdef5 update dependencies 2022-08-02 09:14:36 -04:00
Robert McRackan
fe2de6ecf7 recommended: System.Environment.ProcessPath 2022-08-02 07:58:42 -04:00
Robert McRackan
fc25e73b1a attach avalonia primer notes 2022-08-01 20:56:08 -04:00
Robert McRackan
a3df85c87e Refactor hangover 2022-08-01 11:59:55 -04:00
Robert McRackan
553a936e7e incr ver 2022-08-01 09:49:45 -04:00
Robert McRackan
635764625e incl HangoverAvalonia in sln 2022-08-01 07:46:44 -04:00
rmcrackan
f5599f7c57 Merge pull request #338 from Mbucari/master
HangoverAvalonia and other fixes
2022-08-01 07:36:25 -04:00
Mbucari
dc6aaf2dd6 Updated instructions for Standalone Build (no dotnet runtime required) 2022-07-31 22:37:42 -06:00
Michael Bucari-Tovo
f1ba2b4ae8 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-31 22:24:28 -06:00
Michael Bucari-Tovo
742310b8d6 Fix install workflow 2022-07-31 22:24:17 -06:00
Mbucari
073787173d Merge branch 'rmcrackan:master' into master 2022-07-31 21:59:46 -06:00
Michael Bucari-Tovo
66679ace2f Add HangoverAvalonia 2022-07-31 20:33:56 -06:00
Robert McRackan
3982537d46 Tags no longer saved outside of database 2022-07-31 21:58:53 -04:00
69 changed files with 894 additions and 520 deletions

View File

@@ -2,21 +2,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>8.3.3.1</Version>
<Version>8.3.5.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="1.0.0" />
<PackageReference Include="Octokit" Version="1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>
<PropertyGroup>
<DefineConstants Condition=" '$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Windows)))' ">$(DefineConstants);WINDOWS</DefineConstants>
<DefineConstants Condition=" '$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::Linux)))' ">$(DefineConstants);LINUX</DefineConstants>
<DefineConstants Condition=" '$([System.Runtime.InteropServices.RuntimeInformation]::IsOSPlatform($([System.Runtime.InteropServices.OSPlatform]::OSX)))' ">$(DefineConstants);MACOS</DefineConstants>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>

View File

@@ -14,7 +14,6 @@ using Serilog;
namespace AppScaffolding
{
public enum ReleaseIdentifier
{
None,
@@ -26,6 +25,10 @@ namespace AppScaffolding
public static class LibationScaffolding
{
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
@@ -290,14 +293,11 @@ namespace AppScaffolding
if (System.Diagnostics.Debugger.IsAttached)
mode += " (Debugger attached)";
#if MACOS
var os = "MacOS";
#elif LINUX
var os = "Linux";
#else
var os = "Windows";
#endif
string OS
= IsLinux ? "Linux"
: IsMacOs ? "MacOS"
: IsWindows ? "Windows"
: "UNKNOWN_OS";
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
@@ -305,8 +305,8 @@ namespace AppScaffolding
{
AppName = EntryAssembly.GetName().Name,
Version = BuildVersion.ToString(),
ReleaseIdentifier = ReleaseIdentifier,
OS = os,
ReleaseIdentifier,
OS,
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),

View File

@@ -25,7 +25,7 @@ namespace AppScaffolding
: value;
#region appsettings.json
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName), "appsettings.json");
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="4.6.0.1" />
<PackageReference Include="AudibleApi" Version="4.6.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,7 +10,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.1.1" />
<PackageReference Include="Dinah.Core" Version="5.1.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="5.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
@@ -29,10 +30,6 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="migrate.json">

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{

View File

@@ -30,10 +30,6 @@ namespace DataLayer
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
// import previously saved tags
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
#region Tags

View File

@@ -1,10 +1,9 @@
using DataLayer.Configurations;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public class LibationContext : InterceptableDbContext
public class LibationContext : DbContext
{
// IMPORTANT: USING DbSet<>
// ========================
@@ -35,14 +34,6 @@ namespace DataLayer
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
internal LibationContext(DbContextOptions options) : base(options) { }
// called on each instantiation
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
AddInterceptor(new TagPersistenceInterceptor());
base.OnConfiguring(optionsBuilder);
}
// typically only called once per execution; NOT once per instantiation
protected override void OnModelCreating(ModelBuilder modelBuilder)
{

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core.Collections.Generic;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
internal class TagPersistenceInterceptor : IDbInterceptor
{
public void Executed(DbContext context) { }
public void Executing(DbContext context)
{
var tagsCollection
= context
.ChangeTracker
.Entries()
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
.Select(e => e.Entity as UserDefinedItem)
.Where(udi => udi is not null)
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
.Select(t => (t.Book.AudibleProductId, t.Tags))
.ToList();
LibationFileManager.TagsPersistence.Save(tagsCollection);
}
}
}

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
<PackageReference Include="Dinah.Core" Version="5.1.0.1" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>

View File

@@ -1,140 +0,0 @@
using ApplicationServices;
using AppScaffolding;
using Microsoft.EntityFrameworkCore;
namespace Hangover
{
public partial class Form1
{
private string dbFile;
private void Load_databaseTab()
{
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
if (dbFile is null)
{
databaseFileLbl.Text = $"Database file not found";
return;
}
databaseFileLbl.Text = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
}
private void databaseTab_VisibleChanged(object sender, EventArgs e)
{
if (!databaseTab.Visible)
return;
}
private void sqlExecuteBtn_Click(object sender, EventArgs e)
{
ensureBackup();
sqlResultsTb.Clear();
try
{
var sql = sqlTb.Text.Trim();
#region // explanation
// Routing statements to non-query is a convenience.
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
// -- line 1 is a comment
// delete from foo
#endregion
var lower = sql.ToLower();
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
nonQuery(sql);
else
query(sql);
}
catch (Exception ex)
{
sqlResultsTb.Text = $"{ex.Message}\r\n{ex.StackTrace}";
}
finally
{
deleteUnneededBackups();
}
}
private string dbBackup;
private DateTime dbFileLastModified;
private void ensureBackup()
{
if (dbBackup is not null)
return;
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
dbBackup
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
+ Path.GetExtension(dbFile);
File.Copy(dbFile, dbBackup);
}
private void deleteUnneededBackups()
{
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
if (dbFileLastModified == newLastModified)
{
File.Delete(dbBackup);
dbBackup = null;
}
}
void query(string sql)
{
// ef doesn't support truly generic queries. have to drop down to ado.net
using var context = DbContexts.GetContext();
using var conn = context.Database.GetDbConnection();
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var reader = cmd.ExecuteReader();
var results = 0;
var builder = new System.Text.StringBuilder();
var lines = 0;
while (reader.Read())
{
results++;
for (var i = 0; i < reader.FieldCount; i++)
builder.Append(reader.GetValue(i) + "\t");
builder.AppendLine();
lines++;
if (lines % 10 == 0)
{
sqlResultsTb.AppendText(builder.ToString());
builder.Clear();
}
}
sqlResultsTb.AppendText(builder.ToString());
builder.Clear();
if (results == 0)
sqlResultsTb.Text = "[no results]";
else
{
sqlResultsTb.AppendText($"\r\n{results} result");
if (results != 1) sqlResultsTb.AppendText("s");
}
}
void nonQuery(string sql)
{
using var context = DbContexts.GetContext();
var results = context.Database.ExecuteSqlRaw(sql);
sqlResultsTb.AppendText($"{results} record");
if (results != 1) sqlResultsTb.AppendText("s");
sqlResultsTb.AppendText(" affected");
}
}
}

View File

@@ -0,0 +1,12 @@
<Application xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:HangoverAvalonia"
x:Class="HangoverAvalonia.App">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme Mode="Light"/>
</Application.Styles>
</Application>

View File

@@ -0,0 +1,29 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using HangoverAvalonia.ViewModels;
using HangoverAvalonia.Views;
namespace HangoverAvalonia
{
public partial class App : Application
{
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
}
public override void OnFrameworkInitializationCompleted()
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
};
}
base.OnFrameworkInitializationCompleted();
}
}
}

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,85 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<!--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>
<IsPublishable>true</IsPublishable>
<AssemblyName>Hangover</AssemblyName>
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<StartupObject />
<IsPublishable>true</IsPublishable>
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<ApplicationIcon>hangover.ico</ApplicationIcon>
</PropertyGroup>
<PropertyGroup>
<!--
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
- Not using SatelliteResourceLanguages will load all language packs: works
- Specifying 'en' semicolon 1 more should load 1 language pack: works
- Specifying only 'en' should load no language packs: broken, still loads all
-->
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\bin\Avalonia\Release</OutputPath>
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<AvaloniaResource Include="Assets\**" />
<AvaloniaXaml Remove="Models\**" />
<Compile Remove="Models\**" />
<EmbeddedResource Remove="Models\**" />
<None Remove="Models\**" />
<None Remove=".gitignore" />
<None Remove="Assets\hangover.ico" />
<None Remove="hangover.ico" />
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="hangover.ico" />
</ItemGroup>
<ItemGroup>
<!--This helps with theme dll-s trimming.
If you will publish your application in self-contained mode with p:PublishTrimmed=true and it will use Fluent theme Default theme will be trimmed from the output and vice versa.
https://github.com/AvaloniaUI/Avalonia/issues/5593 -->
<TrimmableAssembly Include="Avalonia.Themes.Fluent" />
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.17" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.17" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.17" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.17" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.3.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
<!-- Remove bin folder -->
<RemoveDir Directories="$(BaseOutputPath)" />
</Target>
</Project>

View File

@@ -0,0 +1,24 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
using System;
namespace HangoverAvalonia
{
internal class Program
{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args) => BuildAvaloniaApp()
.StartWithClassicDesktopLifetime(args);
// Avalonia configuration, don't remove; also used by visual designer.
public static AppBuilder BuildAvaloniaApp()
=> AppBuilder.Configure<App>()
.UsePlatformDetect()
.LogToTrace()
.UseReactiveUI();
}
}

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<!--
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\win-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,30 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using HangoverAvalonia.ViewModels;
using System;
namespace HangoverAvalonia
{
public class ViewLocator : IDataTemplate
{
public IControl Build(object data)
{
var name = data.GetType().FullName!.Replace("ViewModel", "View");
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
else
{
return new TextBlock { Text = "Not Found: " + name };
}
}
public bool Match(object data)
{
return data is ViewModelBase;
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using HangoverBase;
using ReactiveUI;
namespace HangoverAvalonia.ViewModels
{
public class MainWindowViewModel : ViewModelBase
{
private DatabaseTab _tab;
private string _databaseFileText;
private bool _databaseFound;
private string _sqlResults;
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
public string SqlQuery { get; set; }
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
public MainWindowViewModel()
{
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
DatabaseFileText = $"Database file not found";
DatabaseFound = false;
return;
}
DatabaseFileText = $"Database file: {_tab.DbFile}";
DatabaseFound = true;
}
public void ExecuteQuery() => _tab.ExecuteQuery();
}
}

View File

@@ -0,0 +1,11 @@
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Text;
namespace HangoverAvalonia.ViewModels
{
public class ViewModelBase : ReactiveObject
{
}
}

View File

@@ -0,0 +1,75 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:HangoverAvalonia.ViewModels"
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="500"
Width="800" Height="500"
x:Class="HangoverAvalonia.Views.MainWindow"
Icon="/Assets/hangover.ico "
Title="Hangover: Libation debug and recovery tool">
<Design.DataContext>
<vm:MainWindowViewModel/>
</Design.DataContext>
<TabControl Grid.Row="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="18"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="30"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Padding" Value="8,2,8,0"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
</Style>
</TabControl.Styles>
<!-- Database Tab -->
<TabItem>
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
</TabItem.Header>
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
<TextBlock
Margin="0,10,0,5"
Grid.Row="0"
Text="{Binding DatabaseFileText}" />
<TextBlock
Margin="0,5,0,5"
Grid.Row="1"
Text="SQL (database command)" />
<TextBox
Margin="0,5,0,5"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button
Grid.Row="3"
Padding="20,5,20,5"
Content="Execute"
IsEnabled="{Binding DatabaseFound}"
Click="Execute_Click" />
<TextBox
Margin="0,5,0,10"
IsReadOnly="True"
Grid.Row="4"
Text="{Binding SqlResults}" />
</Grid>
</TabItem>
<!-- Command Line Interface Tab -->
<TabItem>
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
</TabItem.Header>
</TabItem>
</TabControl>
</Window>

View File

@@ -0,0 +1,19 @@
using Avalonia.Controls;
using HangoverAvalonia.ViewModels;
namespace HangoverAvalonia.Views
{
public partial class MainWindow : Window
{
MainWindowViewModel _viewModel => DataContext as MainWindowViewModel;
public MainWindow()
{
InitializeComponent();
}
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_viewModel.ExecuteQuery();
}
}
}

View File

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -0,0 +1,154 @@
using System.Text;
using ApplicationServices;
using AppScaffolding;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
namespace HangoverBase
{
public class DatabaseTabCommands
{
public Func<string> SqlInput { get; }
public Action<string> SqlOutputAppend { get; }
public Action<string> SqlOutputOverwrite { get; }
public DatabaseTabCommands() { }
public DatabaseTabCommands(
Func<string> sqlInput,
Action<string> sqlDisplayAppend,
Action<string> sqlDisplayOverwrite)
{
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
}
}
public class DatabaseTab
{
private DatabaseTabCommands _commands { get; }
public string DbFile { get; private set; }
public DatabaseTab(DatabaseTabCommands commands)
{
_commands = ArgumentValidator.EnsureNotNull(commands, nameof(commands));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
}
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
private string dbBackup;
private DateTime dbFileLastModified;
public void EnsureBackup()
{
if (dbBackup is not null)
return;
dbFileLastModified = File.GetLastWriteTimeUtc(DbFile);
dbBackup
= Path.ChangeExtension(DbFile, "").TrimEnd('.')
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
+ Path.GetExtension(DbFile);
File.Copy(DbFile, dbBackup);
}
public void ExecuteQuery()
{
EnsureBackup();
_commands.SqlOutputOverwrite("");
try
{
var sql = _commands.SqlInput().Trim();
#region // explanation
// Routing statements to non-query is a convenience.
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
// -- line 1 is a comment
// delete from foo
#endregion
var lower = sql.ToLower();
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
NonQuery(sql);
else
Query(sql);
}
catch (Exception ex)
{
_commands.SqlOutputOverwrite($"{ex.Message}\r\n{ex.StackTrace}");
}
finally
{
DeleteUnneededBackups();
}
}
public void DeleteUnneededBackups()
{
var newLastModified = File.GetLastWriteTimeUtc(DbFile);
if (dbFileLastModified == newLastModified)
{
File.Delete(dbBackup);
dbBackup = null;
}
}
public void Query(string sql)
{
// ef doesn't support truly generic queries. have to drop down to ado.net
using var context = DbContexts.GetContext();
using var conn = context.Database.GetDbConnection();
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var reader = cmd.ExecuteReader();
var results = 0;
var builder = new StringBuilder();
var lines = 0;
while (reader.Read())
{
results++;
for (var i = 0; i < reader.FieldCount; i++)
builder.Append(reader.GetValue(i) + "\t");
builder.AppendLine();
lines++;
if (lines % 10 == 0)
{
_commands.SqlOutputAppend(builder.ToString());
builder.Clear();
}
}
_commands.SqlOutputAppend(builder.ToString());
builder.Clear();
if (results == 0)
_commands.SqlOutputOverwrite("[no results]");
else
{
_commands.SqlOutputAppend($"\r\n{results} result");
if (results != 1) _commands.SqlOutputAppend("s");
}
}
public void NonQuery(string sql)
{
using var context = DbContexts.GetContext();
var results = context.Database.ExecuteSqlRaw(sql);
_commands.SqlOutputAppend($"{results} record");
if (results != 1) _commands.SqlOutputAppend("s");
_commands.SqlOutputAppend(" affected");
}
}
}

View File

@@ -0,0 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -1,6 +1,6 @@
using AppScaffolding;
namespace Hangover
namespace HangoverWinForms
{
public partial class Form1
{

View File

@@ -0,0 +1,31 @@
using HangoverBase;
namespace HangoverWinForms
{
public partial class Form1
{
private DatabaseTab _tab;
private void Load_databaseTab()
{
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
databaseFileLbl.Text = $"Database file not found";
return;
}
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
}
private void databaseTab_VisibleChanged(object sender, EventArgs e)
{
if (!databaseTab.Visible)
return;
}
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
}
}

View File

@@ -1,4 +1,4 @@
namespace Hangover
namespace HangoverWinForms
{
partial class Form1
{

View File

@@ -1,4 +1,4 @@
namespace Hangover
namespace HangoverWinForms
{
public partial class Form1 : Form
{

View File

@@ -44,9 +44,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
</ItemGroup>
<ItemGroup>
@@ -55,7 +53,7 @@
</Compile>
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
<!-- Remove bin folder -->

View File

@@ -1,4 +1,4 @@
namespace Hangover
namespace HangoverWinForms
{
internal static class Program
{

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 498 B

View File

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 18 KiB

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 50 KiB

View File

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

@@ -8,8 +8,8 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solutio
REFERENCE.txt = REFERENCE.txt
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
_DB_NOTES.txt = _DB_NOTES.txt
_AvaloniaUI Primer.txt = _AvaloniaUI Primer.txt
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
@@ -64,10 +64,14 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangover", "Hangover\Hangover.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverWinForms", "HangoverWinForms\HangoverWinForms.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationAvalonia", "LibationAvalonia\LibationAvalonia.csproj", "{F612D06F-3134-4B9B-95CD-EB3FC798AE60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverAvalonia", "HangoverAvalonia\HangoverAvalonia.csproj", "{8A7B01D3-9830-44FD-91A1-D8D010996BEB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverBase", "HangoverBase\HangoverBase.csproj", "{5C7005BA-7D83-4E99-8073-D970943A7D61}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -150,6 +154,14 @@ Global
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Release|Any CPU.Build.0 = Release|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.Build.0 = Release|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -174,6 +186,8 @@ Global
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{F612D06F-3134-4B9B-95CD-EB3FC798AE60} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8A7B01D3-9830-44FD-91A1-D8D010996BEB} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{5C7005BA-7D83-4E99-8073-D970943A7D61} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

View File

@@ -17,16 +17,6 @@ namespace LibationAvalonia
{
public class App : Application
{
public static readonly bool IsWindows;
public static readonly bool IsLinux;
public static readonly bool IsMacOs;
static App()
{
IsWindows = OperatingSystem.IsWindows();
IsLinux = OperatingSystem.IsLinux();
IsMacOs = OperatingSystem.IsMacOS();
}
public static IBrush ProcessQueueBookFailedBrush { get; private set; }
public static IBrush ProcessQueueBookCompletedBrush { get; private set; }
public static IBrush ProcessQueueBookCancelledBrush { get; private set; }
@@ -39,33 +29,6 @@ namespace LibationAvalonia
public static Stream OpenAsset(string assetRelativePath)
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
public static bool GoToFile(string path)
=> IsWindows ? Go.To.File(path)
: GoToFolder(path is null ? string.Empty : Path.GetDirectoryName(path));
public static bool GoToFolder(string path)
{
if (IsWindows)
return Go.To.Folder(path);
else if (IsLinux)
{
var startInfo = new System.Diagnostics.ProcessStartInfo()
{
FileName = "/bin/xdg-open",
Arguments = path is null ? string.Empty : $"\"{path}\"",
UseShellExecute = false, //Import in Linux environments
CreateNoWindow = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
System.Diagnostics.Process.Start(startInfo);
return true;
}
//Don't know how to do this for mac yet
else return true;
}
public override void Initialize()
{
AvaloniaXamlLoader.Load(this);
@@ -82,7 +45,6 @@ namespace LibationAvalonia
var SEGOEUI = new Typeface(new FontFamily(new Uri("avares://Libation/Assets/WINGDING.TTF"), "SEGOEUI_Local"));
var gtf = FontManager.Current.GetOrAddGlyphTypeface(SEGOEUI);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
if (SetupRequired)
@@ -125,28 +87,21 @@ namespace LibationAvalonia
// all returns should be preceded by either:
// - if config.LibationSettingsAreValid
// - error message, Exit()
if ((!setupDialog.IsNewUser
&& !setupDialog.IsReturningUser) ||
!await RunInstall(setupDialog))
if (setupDialog.IsNewUser)
{
setupDialog.Config.SetLibationFiles(Configuration.UserProfile);
ShowSettingsWindow(desktop, setupDialog.Config, OnSettingsCompleted);
}
else if (setupDialog.IsReturningUser)
{
ShowLibationFilesDialog(desktop, setupDialog.Config, OnLibationFilesCompleted);
}
else
{
await CancelInstallation();
return;
}
// most migrations go in here
AppScaffolding.LibationScaffolding.RunPostConfigMigrations(setupDialog.Config);
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
#if !DEBUG
//AutoUpdater.NET only works for WinForms or WPF application projects.
//checkForUpdate();
#endif
// logging is init'd here
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(setupDialog.Config);
}
catch (Exception ex)
{
@@ -162,32 +117,83 @@ namespace LibationAvalonia
}
return;
}
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
private static async Task<bool> RunInstall(SetupDialog setupDialog)
private async Task RunMigrationsAsync(Configuration config)
{
var config = setupDialog.Config;
// most migrations go in here
AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config);
if (setupDialog.IsNewUser)
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(config);
}
private void ShowSettingsWindow(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action<IClassicDesktopStyleApplicationLifetime, SettingsDialog, Configuration> OnClose)
{
config.Books ??= Path.Combine(Configuration.UserProfile, "Books");
AppScaffolding.LibationScaffolding.PopulateMissingConfigValues(config);
var settingsDialog = new SettingsDialog();
desktop.MainWindow = settingsDialog;
settingsDialog.RestoreSizeAndLocation(Configuration.Instance);
settingsDialog.Show();
void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e)
{
config.SetLibationFiles(Configuration.UserProfile);
settingsDialog.Closing -= WindowClosing;
e.Cancel = true;
OnClose?.Invoke(desktop, settingsDialog, config);
}
else if (setupDialog.IsReturningUser)
settingsDialog.Closing += WindowClosing;
}
private async void OnSettingsCompleted(IClassicDesktopStyleApplicationLifetime desktop, SettingsDialog settingsDialog, Configuration config)
{
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
await CancelInstallation();
var libationFilesDialog = new LibationFilesDialog();
settingsDialog.Close();
}
if (await libationFilesDialog.ShowDialog<DialogResult>(setupDialog) != DialogResult.OK)
return false;
config.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
return true;
private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action<IClassicDesktopStyleApplicationLifetime, LibationFilesDialog, Configuration> OnClose)
{
var libationFilesDialog = new LibationFilesDialog();
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Show();
void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e)
{
libationFilesDialog.Closing -= WindowClosing;
e.Cancel = true;
OnClose?.Invoke(desktop, libationFilesDialog, config);
}
libationFilesDialog.Closing += WindowClosing;
}
private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config)
{
config.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
// path did not result in valid settings
var continueResult = await MessageBox.Show(
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
@@ -195,17 +201,13 @@ namespace LibationAvalonia
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (continueResult != DialogResult.Yes)
return false;
if (continueResult == DialogResult.Yes)
ShowSettingsWindow(desktop, config, OnSettingsCompleted);
else
await CancelInstallation();
}
// INIT DEFAULT SETTINGS
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
config.Books ??= Path.Combine(Configuration.UserProfile, "Books");
AppScaffolding.LibationScaffolding.PopulateMissingConfigValues(config);
return await new SettingsDialog().ShowDialog<DialogResult>(setupDialog) == DialogResult.OK
&& config.LibationSettingsAreValid;
libationFilesDialog.Close();
}
static async Task CancelInstallation()

View File

@@ -51,7 +51,7 @@ namespace LibationAvalonia.Dialogs
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Jpeg", Extensions = new System.Collections.Generic.List<string>() { "jpg" } });
saveFileDialog.InitialFileName = PictureFileName;
saveFileDialog.Directory
= !App.IsWindows ? null
= !AppScaffolding.LibationScaffolding.IsWindows ? null
: Directory.Exists(BookSaveDirectory) ? BookSaveDirectory
: Path.GetDirectoryName(BookSaveDirectory);

View File

@@ -7,6 +7,7 @@
MinWidth="800" MaxWidth="800"
x:Class="LibationAvalonia.Dialogs.LibationFilesDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
WindowStartupLocation="CenterScreen"
Title="Book Details"
Icon="/Assets/libation.ico">

View File

@@ -23,6 +23,7 @@ namespace LibationAvalonia.Dialogs
}
private DirSelectOptions dirSelectOptions;
public string SelectedDirectory => dirSelectOptions.Directory;
public DialogResult DialogResult { get; private set; }
public LibationFilesDialog()
{
InitializeComponent();
@@ -44,7 +45,8 @@ namespace LibationAvalonia.Dialogs
return;
}
Close(DialogResult.OK);
DialogResult = DialogResult.OK;
Close(DialogResult);
}
private void InitializeComponent()

View File

@@ -52,7 +52,7 @@ namespace LibationAvalonia.Dialogs
try
{
App.GoToFolder(dir.ShortPathName);
Go.To.Folder(dir.ShortPathName);
}
catch
{

View File

@@ -495,6 +495,7 @@
<RadioButton
Margin="0,5,0,5"
IsEnabled="{Binding AudioSettings.IsMp3Supported}"
IsChecked="{Binding AudioSettings.DecryptToLossy, Mode=TwoWay}">
<TextBlock
@@ -508,6 +509,7 @@
<StackPanel
Grid.Row="0"
IsVisible="{Binding AudioSettings.IsMp3Supported}"
Grid.Column="1">
<controls:GroupBox

View File

@@ -45,7 +45,7 @@ namespace LibationAvalonia.Dialogs
public void OpenLogFolderButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
App.GoToFolder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
}
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -381,6 +381,8 @@ namespace LibationAvalonia.Dialogs
private int _lameVBRQuality;
private string _chapterTitleTemplate;
public bool IsMp3Supported => AppScaffolding.LibationScaffolding.IsLinux || AppScaffolding.LibationScaffolding.IsWindows;
public AudioSettings(Configuration config)
{
LoadSettings(config);

View File

@@ -113,7 +113,7 @@ namespace LibationAvalonia
public static void HideMinMaxBtns(this Window form)
{
if (Design.IsDesignMode || !App.IsWindows)
if (Design.IsDesignMode || !AppScaffolding.LibationScaffolding.IsWindows)
return;
var handle = form.PlatformImpl.Handle.Handle;
var currentStyle = GetWindowLong(handle, GWL_STYLE);

View File

@@ -30,11 +30,11 @@ namespace LibationAvalonia
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
if (App.IsWindows)
if (AppScaffolding.LibationScaffolding.IsWindows)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.WindowsAvalonia);
else if (App.IsLinux)
else if (AppScaffolding.LibationScaffolding.IsLinux)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.LinuxAvalonia);
else if (App.IsMacOs)
else if (AppScaffolding.LibationScaffolding.IsMacOs)
AppScaffolding.LibationScaffolding.SetReleaseIdentifier(AppScaffolding.ReleaseIdentifier.MacOSAvalonia);
else return;

View File

@@ -3,14 +3,15 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -3,14 +3,15 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -4,54 +4,9 @@ This walkthrough should get you up and running with Libation on your Ubuntu mach
Some limitations of the linux release are:
- Cannot customize how illegial filename characters are replaced.
- The Auto-update function is unavailable
- The "Hangover" app for debugging is not yet available.
## Dependencies
### Dotnet Runtime
You must install the dotnet 6.0 runtime on your machine.
First, add the Microsoft package signing key to your list of trusted keys and add the package repository.
<details>
<summary>Ubuntu 22.04</summary>
```console
wget https://packages.microsoft.com/config/ubuntu/22.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
```
</details>
<details>
<summary>Ubuntu 21.10</summary>
```console
wget https://packages.microsoft.com/config/ubuntu/21.10/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
```
</details>
<details>
<summary>Ubuntu 20.04</summary>
```console
wget https://packages.microsoft.com/config/ubuntu/20.04/packages-microsoft-prod.deb -O packages-microsoft-prod.deb
sudo dpkg -i packages-microsoft-prod.deb
rm packages-microsoft-prod.deb
```
</details>
For other distributions, see [Microsoft's instructions for installing .NET on Linux](https://docs.microsoft.com/en-us/dotnet/core/install/linux).
Then install the dotnet 6.0 runtime
```console
sudo apt-get update; \
sudo apt-get install -y apt-transport-https && \
sudo apt-get update && \
sudo apt-get install -y dotnet-runtime-6.0
```
### FFMpeg (Optional)
If you want to convert your audiobooks to mp3, install FFMpeg using the following command:
@@ -67,8 +22,7 @@ Download the most recent linux-64 binaries zip file and save it as `libation-lin
<summary>install-libation.sh</summary>
```BASH
#!/bin/bash
#!/bin/bash
FILE=$1
@@ -77,10 +31,10 @@ Download the most recent linux-64 binaries zip file and save it as `libation-lin
exit
fi
if [ "$EUID" -ne 0 ]
then echo "Please run as root"
exit
fi
if [[ "$EUID" -ne 0 ]]
then echo "Please run as root"
exit
fi
if [ ! -f "$FILE" ]
then echo "The file \"$FILE\" does not exist."
@@ -99,12 +53,13 @@ fi
exit
fi
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Libation
sudo -u $SUDO_USER chmod +700 ${FOLDER}/Hangover
sudo -u $SUDO_USER chmod +700 ${FOLDER}/LibationCli
#Remove previous installation program files and sym link
rm /usr/bin/Libation
rm /usr/bin/Hangover
rm /usr/bin/LibationCli
rm /usr/bin/libationcli
rm /usr/lib/libation -r
@@ -117,11 +72,11 @@ fi
chmod +666 /usr/share/icons/hicolor/scalable/apps/libation.svg
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/Libation
ln -s /usr/lib/libation/Hangover /usr/bin/Hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/LibationCli
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
echo "Done!"
```
</details>

View File

@@ -19,6 +19,7 @@ namespace LibationAvalonia.ViewModels
private int _visibleCount = 1;
private LibraryCommands.LibraryStats _libraryStats;
private int _visibleNotLiberated = 1;
public bool IsMp3Supported => AppScaffolding.LibationScaffolding.IsLinux || AppScaffolding.LibationScaffolding.IsWindows;
/// <summary> The Process Queue's viewmodel </summary>
public ProcessQueueViewModel ProcessQueue { get; } = new ProcessQueueViewModel();

View File

@@ -36,7 +36,7 @@ namespace LibationAvalonia.Views
// liberated: open explorer to file
var filePath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
if (!App.GoToFile(filePath?.ShortPathName))
if (!Go.To.File(filePath?.ShortPathName))
{
var suffix = string.IsNullOrWhiteSpace(filePath) ? "" : $":\r\n{filePath}";
await MessageBox.Show($"File not found" + suffix);

View File

@@ -65,7 +65,7 @@
</MenuItem.Styles>
<MenuItem Click="beginBookBackupsToolStripMenuItem_Click" Header="{Binding BookBackupsToolStripText}" />
<MenuItem Click="beginPdfBackupsToolStripMenuItem_Click" Header="{Binding PdfBackupsToolStripText}" />
<MenuItem Click="convertAllM4bToMp3ToolStripMenuItem_Click" Header="Convert all _M4b to Mp3 [Long-running]..." />
<MenuItem Click="convertAllM4bToMp3ToolStripMenuItem_Click" Header="Convert all _M4b to Mp3 [Long-running]..." IsVisible="{Binding IsMp3Supported}" />
<MenuItem Click="liberateVisible" Header="{Binding LiberateVisibleToolStripText}" IsEnabled="{Binding AnyVisibleNotLiberated}" />
</MenuItem>

View File

@@ -70,7 +70,7 @@ namespace LibationAvalonia.Views
#if !DEBUG
//This is temporaty until we have a solution for linux/mac so that
//Libation doesn't download a zip every time it runs.
if (!App.IsWindows)
if (!LibationScaffolding.IsWindows)
return;
try
@@ -85,15 +85,15 @@ namespace LibationAvalonia.Views
if (result != DialogResult.Yes)
return;
if (App.IsWindows)
if (LibationScaffolding.IsWindows)
{
runWindowsUpgrader(zipFile);
}
else if (App.IsLinux)
else if (LibationScaffolding.IsLinux)
{
}
else if (App.IsMacOs)
else if (LibationScaffolding.IsMacOs)
{
}
@@ -149,8 +149,7 @@ namespace LibationAvalonia.Views
private void runWindowsUpgrader(string zipFile)
{
var thisExe = System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName;
var thisExe = Environment.ProcessPath;
var thisDir = System.IO.Path.GetDirectoryName(thisExe);
var args = $"--input {zipFile} --output {thisDir} --executable {thisExe}";

View File

@@ -3,14 +3,15 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -3,14 +3,15 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>false</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
</PropertyGroup>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -3,15 +3,15 @@
https://go.microsoft.com/fwlink/?LinkID=208121.
-->
<Project>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\win-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
<PropertyGroup>
<Configuration>Release</Configuration>
<Platform>Any CPU</Platform>
<PublishDir>..\bin\Release\win-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net6.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile>
<PublishTrimmed>false</PublishTrimmed>
</PropertyGroup>
</Project>

View File

@@ -1,70 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Newtonsoft.Json;
using Polly;
using Polly.Retry;
namespace LibationFileManager
{
/// <summary>
/// Tags must also be stored in db for search performance. Stored in json file to survive a db reset.
/// json is only read when a product is first loaded into the db
/// json is only written to when tags are edited
/// json access is infrequent and one-off
/// </summary>
public static class TagsPersistence
{
private static string TagsFile => Path.Combine(Configuration.Instance.LibationFiles, "BookTags.json");
private static object locker { get; } = new object();
// if failed, retry only 1 time after a wait of 100 ms
// 1st save attempt sometimes fails with
// The requested operation cannot be performed on a file with a user-mapped section open.
private static RetryPolicy policy { get; }
= Policy.Handle<Exception>()
.WaitAndRetry(new[] { TimeSpan.FromMilliseconds(100) });
public static void Save(IEnumerable<(string productId, string tags)> tagsCollection)
{
ensureCache();
if (!tagsCollection.Any())
return;
// on initial reload, there's a huge benefit to adding to cache individually then updating the file only once
foreach ((string productId, string tags) in tagsCollection)
cache[productId] = tags;
lock (locker)
policy.Execute(() => File.WriteAllText(TagsFile, JsonConvert.SerializeObject(cache, Formatting.Indented)));
}
private static Dictionary<string, string> cache;
public static string GetTags(string productId)
{
ensureCache();
cache.TryGetValue(productId, out string value);
return value;
}
private static void ensureCache()
{
if (cache is not null)
return;
lock (locker)
{
if (File.Exists(TagsFile))
cache = JsonConvert.DeserializeObject<Dictionary<string, string>>(File.ReadAllText(TagsFile));
// if file doesn't exist. or if file is corrupt and deserialize returns null
cache ??= new Dictionary<string, string>();
}
}
}
}

View File

@@ -14,6 +14,7 @@
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -44,7 +44,7 @@
<ItemGroup>
<PackageReference Include="Autoupdater.NET.Official" Version="1.7.4" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="4.2.3.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="5.0.0.1" />
</ItemGroup>
<ItemGroup>
@@ -89,7 +89,7 @@
</EmbeddedResource>
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
<!-- Remove bin folder -->

View File

@@ -0,0 +1,104 @@
I don't expect you'll fully understand everything I said in this writeup on your first read, but I think they'll make much more sense after you've poked around LibationAvalonia and started reading code and wonder 'WTF?'. These are the brief explanations I wish I had while I was learning.
- Mbucari
===================================
Controls Basics
===============
Avalonia uses a xml layout with code-behind like WPF. It's actually a port of WPF, thus most of the Avalonia controls are also WPF controls. There are lots of built-in controls that you can browse in the documentation, but more important than understanding every specific control is understanding the few base controls.
*TemplatedControl - This base control allows you to define a layout template. The TemplatedControl.Template property is used to define the control's appearance.
*ContentControl - This base control is what allows other controls to be nested inside of it, like so:
<ContentControl>
<Control />
</ContentControl>
Doing that in XML sets the ContentControl.Content property to the <Control />
Common ContentContols:
- Button
- Label
- UserControl
*ItemsControl - This base control allows you to supply IEnumerable for content, and will display the items in the IEnumerable in some way, such as items in a ComboBox or ListBox, by setting the ItemsControl.Items property.
Common ItemsControls:
- DataGrid
- ListBox
- ComboBox
Extending Controls
==================
If you want to customize an Avalonia control by extending it and adding custom functionality. If you do this you MUST make your extended control inherit IStyleable.
See LibationAvalonia.Controls.LinkLabel for an example.
Data Binding
============
Every control implements IDataContextProvider. IDataContextProvider.DataContext is how you bind data to controls. It's null by default, but you need to set it for MVVM to work.
The simplest way to use data binding is to set the window/controls' DataContext to it's own instance. Just add the following line to the window/controls' constructor
this.DataContext = this;
Then, every public 'this' property is accessible in XML with data binding. For a simple example of that working, look at LibationAvalonia.Dialogs.Login.CaptchaDialog.
CaptchaDialog.axaml.cs has 2 public properties:
public string Answer { get; set; }
public Bitmap CaptchaImage { get; }
Inside CaptchaDialog.axaml, I bound the Image control to CaptchaImage like so:
<Image Source="{Binding CaptchaImage}" />
and I bound the TextBox control to Answer like so:
<TextBox Text="{Binding Answer}" />
Because Answer has public getter and setter, the binding mode is TwoWay. That means that changes to the binding source (the CaptchaDialog.Answer property) go to the binding target (the TextBox Control), and changes to the binding target (caused by the user typing text into the textbox) get pushed back to the binding source.
this example is lacking any change notifications, so the view will only get the Answer and CaptchaImage values on load. So those values MUST be set before the window is shown. If you change those properties' values while the window is shown, the view won't know they changed so it will not update to display the new values.
Data Binding to an INotifyPropertyChanged.
=========================================
One of the limitations of binding to 'this' is that you won't be able to notify the view that the value has changed. Change notification works using the INotifyPropertyChanged interface that you're already familiar with. For your DataContext to be able to notify the view that a value has changed it must be INotifyPropertyChanged. Avalonia uses ReactiveUI for this, but it's the same interface. For a (somewhat) simple example of this, see LibationAvalonia.Dialogs.EditQuickFilters.
In EditQuickFilters I set this.DataContext = _viewModel = new EditTemplateViewModel();
EditTemplateViewModel inherits ViewModelBase, which inherits ReactiveObject. Because it inherits ReactiveObject, I can call the ReactiveUI extension methods for notifying the view that a property has changed:
- this.RaiseAndSetIfChanged()
- this.RaisePropertyChanged()
Doing this will notify the view that the property has changed, and the view will update itself by getting the new value.
NOTE: unlike winforms, calling RaisePropertyChanged() with an empty or incorrect property name will not cause the whole view to update. In Avalonia you MUST call RaisePropertyChanged() for every property you want updated, and everything must be spelled correctly.
Binding to ItemsControl.Items
=============================
As I said above, the ItemsControl.Items property accepts an IEnumerable. You can bind a list to that property, and the ItemsControl will generate a new item for each list entry. Each generated item will be bound to its corresponding list entry.
An example of this can be found in LibationAvalonia.Dialogs.EditTemplateDialog. I set EditTemplateDialog's DataContext = EditTemplateViewModel. EditTemplateViewModel.ListItems is a list of TemplateTags. In EditTemplateDialog.axaml, I bound EditTemplateViewModel.ListItems to DataGrid.Items like so:
<DataGrid Items="{Binding ListItems}" >
By doing that, the DataGrid generates a new row for every TemplateTags item in ListItems, and the rows's DataContext is set to the TemplateTags that generated it.
That DataGrid has two column templates: Tag and Description. The Tag column is bound to TemplateTags.TagName, and the Description column is bound to TagName.Description. Binding the DataGrid to a List<TemplateTags> and binding column templates to TemplateTags properties is all you need to do for all your data to be displayed in the grid. This works the same for all ItemsControl controls. For another example using the ListBox control, see LibationAvalonia.Dialogs.ScanAccountsDialog.
Binding to ItemsControl.Items with notifications that the Items list has changed
================================================================================
Binding ItemsControl to a List is fine for a static display, but if you want changes to the list update the control (e.g. removing an Account from the accounts list or a Filter from the filters list in EditQuickFilters), you need to use an IEnumerable type that implements INotifyCollectionChanged. INotifyCollectionChanged is just like INotifyPropertyChanged but is for items in a collection. C#'s built-in tracking collection is System.Collections.ObjectModel.ObservableCollection<T>. If you put all items in an ObservableCollection<T> and bind that collection to a ItemsControl.Items property, then adding or removing items from that collection will cause the view to add or remove items. This is similar to BindingList<T>, with one very important difference: The ObservableCollection<T> is not aware of changes made to any of its items, only changes made to the collection.
For an example of binding ItemsControl.Items to an ObservableCollection<T>, see LibationAvalonia.Dialogs.EditQuickFilters.

View File

@@ -1,26 +0,0 @@
-- begin PERSISTENCE ---------------------------------------------------------------------------------------------------------------------
user defined item persistence:
static events feel klugey. what's the right way to do them?
see also HACK note in UserDefinedItem.OnItemChanged()
would it be any better to use Interception of database operations in LibationContextFactory.cs?
https://devblogs.microsoft.com/dotnet/announcing-ef-core-3-0-and-ef-6-3-general-availability/#interception-of-database-operations
db originally was only things which came from audible. all else was stored in json files. user def'd tags stored in both. json is canonical, db is for search engine access
this allows easy db delete for testing, when migrations went bad, and when certain migrations weren't possible w/sqlite, pre .net5. This was also an easy way to remove returned books -- just delete db file and re-scan.
starting with stateful liberation status, the db is not as disposible
-- end PERSISTENCE ---------------------------------------------------------------------------------------------------------------------
-- begin CATEGORIES ---------------------------------------------------------------------------------------------------------------------
fully support multiple categories
learn about the different CategoryLadder.Root enums. probably only need Root.Genres
2020-05-26 -- audible started using categories nested more than 2 deep. to compensate, libation was changed in this hack way:
search for comments: "// CATEGORY HACK: "
CategoryImporter.upsertCategories // CATEGORY HACK: not yet supported: depth beyond 0 and 1
BookInporter.createNewBook // CATEGORY HACK: only use the 1st 2 categories
result: only at most 1st 2 categories (parent and child) will be captured
similar legacy knowledge exists elsewhere. eg:
Book.Category stuff
public partial class Item.Categories
-- end CATEGORIES ---------------------------------------------------------------------------------------------------------------------