Compare commits

...

27 Commits

Author SHA1 Message Date
Robert McRackan
2f347e83e8 fix linux 'can update'. upgrade aaxclean 2023-02-16 07:57:36 -05:00
rmcrackan
080a74884d Update InstallOnMac.md
new mac setup video
2023-02-16 07:44:09 -05:00
Robert McRackan
2dbeb64c38 incr ver. updates for mac and linux 2023-02-15 08:38:13 -05:00
rmcrackan
bb508c0718 Merge pull request #489 from Mbucari/master
Mac App Bundle and added mp3 conversion support on mac
2023-02-15 08:33:06 -05:00
Michael Bucari-Tovo
9a450b0d63 add 'macOS' to mac bundle name 2023-02-15 06:31:09 -07:00
Michael Bucari-Tovo
c1de0e60d2 Hopefully fix #492 2023-02-14 23:07:40 -07:00
Mbucari
dc7c03661d Add auto update to linux and macos 2023-02-14 23:06:14 -07:00
Mbucari
952eee6d32 Merge branch 'rmcrackan:master' into master 2023-02-13 21:42:11 -07:00
Michael Bucari-Tovo
472a0f30b9 Launch hangover from Libation app bundle for mac 2023-02-13 21:40:53 -07:00
Robert McRackan
73533c58a8 update dependencies 2023-02-13 21:14:56 -05:00
Mbucari
65ef018719 Move NameListFormatter to its own class 2023-02-13 10:09:13 -07:00
Mbucari
f0ca349539 Update UNSAFE_MigrationHelper with new appsettings.json getter 2023-02-13 09:03:03 -07:00
Mbucari
500b287721 Fix #490 2023-02-13 08:08:10 -07:00
Mbucari
21f3ae45d3 Delete deb.yml 2023-02-12 22:25:39 -07:00
Michael Bucari-Tovo
d496564f0d Edit Mac and Linux bundle build workflows 2023-02-12 21:50:33 -07:00
Michael Bucari-Tovo
6fdd6293ce Ensure appsettings.json is created in a writable location. 2023-02-12 15:32:51 -07:00
Michael Bucari-Tovo
3bca495521 Add MacOS app bundle workflow 2023-02-11 23:38:17 -07:00
Michael Bucari-Tovo
0fb580f1a5 Ensure appsettings.json is created in a writable location. 2023-02-11 20:06:04 -07:00
Michael Bucari-Tovo
a7cd47e0b1 Update AAXClean 2023-02-11 18:34:07 -07:00
Robert McRackan
30aecedfae incr ver 2023-02-10 23:16:22 -05:00
rmcrackan
e72799efe5 Merge pull request #487 from Mbucari/master
Custom author and narrator names formatting and batch locate books
2023-02-10 23:14:31 -05:00
Michael Bucari-Tovo
ee8c0ae27b Use new .NET regular expression source generators 2023-02-10 19:45:52 -07:00
Mbucari
5b4a4341ad More agressive garbage collection 2023-02-10 15:03:43 -07:00
Mbucari
56823c1105 Move FindAudiobooks() to AudioFileStorage 2023-02-10 14:54:29 -07:00
Mbucari
1f4ada604a Make suggested changes 2023-02-10 14:37:28 -07:00
Mbucari
3a4ab80892 Add human name parsing and formatting to naming templates 2023-02-10 12:53:12 -07:00
Mbucari
bba9c2ba7b Add Locate Audiobooks function (#485) 2023-02-10 09:35:21 -07:00
77 changed files with 1770 additions and 329 deletions

View File

@@ -66,7 +66,7 @@ jobs:
id: zip
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
run: |
delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll" "ZipExtractor.exe")
delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll")
for n in "${delfiles[@]}"; do rm "$n"; done
osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')"
artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}"

43
.github/workflows/bundle-linux.yml vendored Normal file
View File

@@ -0,0 +1,43 @@
# build-linux.yml
# Reusable workflow that builds the Libation installation bundles for Linux and MacOS.
---
name: bundle-linux
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
jobs:
bundle:
runs-on: ubuntu-latest
strategy:
matrix:
os: [linux, macos]
release_name: [chardonnay]
steps:
- uses: actions/checkout@v3
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz"
- name: Build bundle
id: build
run: |
SCRIPT=targz2${{ matrix.os }}bundle.sh
chmod +rwx ./Scripts/${SCRIPT}
./Scripts/${SCRIPT} "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz" ${{ inputs.version }}
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v3
with:
name: ${{ steps.build.outputs.artifact }}
path: ./bundle/${{ steps.build.outputs.artifact }}
if-no-files-found: error

View File

@@ -1,38 +0,0 @@
# deb.yml
# Reusable workflow that builds the Linux Debian package.
---
name: deb
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
env:
FILE_NAME: "Libation.${{ inputs.version }}-linux-chardonnay"
jobs:
build_deb:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: "${{ env.FILE_NAME }}.tar.gz"
- name: Build .deb
id: deb
run: |
./Scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }}
- name: Publish .deb
uses: actions/upload-artifact@v3
with:
name: ${{ env.FILE_NAME }}.deb
path: ${{ env.FILE_NAME }}.deb
if-no-files-found: error

View File

@@ -34,14 +34,14 @@ jobs:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
deb:
bundle:
needs: [prerelease,build]
uses: ./.github/workflows/deb.yml
uses: ./.github/workflows/bundle-linux.yml
with:
version: ${{ needs.prerelease.outputs.version }}
release:
needs: [prerelease,build,deb]
needs: [prerelease,build,bundle]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
@@ -53,7 +53,7 @@ jobs:
id: release
uses: softprops/action-gh-release@v1
with:
name: Libation ${{ needs.prerelease.outputs.version }}
name: Libation v${{ needs.prerelease.outputs.version }}
body: <Put a body here>
draft: true
prerelease: false

View File

@@ -1,6 +1,6 @@
{
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay\\.deb",
"MacOSAvalonia": "Libation\\.app-macOS-x64-\\d+\\.\\d+\\.\\d+\\.tgz"
}

View File

@@ -4,41 +4,37 @@
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
- Download latest MacOS zip to downloads folder
- Extract and rename folder to Libation
- in terminal type cd and then drag your folder of libation to terminal so it looks like `cd/users/YourName/Downloads/Libation`
- Type following commands
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
- Move the extracted Libation app bundle to your applications folder.
- Open a terminal (Go > Utilities > Terminal)
- Copy/paste/run the following command (you'll be prompted to enter your password)
```Console
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
```
- Close the terminal and use Libation!
```console
chmod +x ./Libation
sudo spctl --add --label "Libation" ./Libation
./Libation
## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command:
```Console
open /Applications/Libation.app --args hangover
```
## Trouble with Gatekeeper?
## Runnign LibationCli
If Gatekeeper is giving you trouble with Libation:
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
```Console
open /Applications/Libation.app --args cli
```
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
Disable the block
`sudo spctl --master-disable`
Launch Libation and login, etc. and allow the rules to update then re-enable the block.
`sudo spctl --master-enable`
Once Gatekeeper reenabled, you can open Libation again without it being blocked.
Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)!
Report bugs to https://github.com/rmcrackan/Libation/issues
Then use `./LibationCli` to execute a command.
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/213933357-983d8ede-2738-4b32-9c6e-40de21ff09c2.mp4)
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)

View File

@@ -10,6 +10,7 @@ These templates apply to both GUI and CLI.
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Name List Formatters](#name-list-formatters)
- [Integer Formatters](#integer-formatters)
- [Date Formatters](#date-formatters)
@@ -26,9 +27,9 @@ These tags will be replaced in the template with the audiobook's values.
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<author\>|Author(s)|Text|
|\<author\>|Author(s)|Name List|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Text|
|\<narrator\>|Narrator(s)|Name List|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Text|
@@ -73,7 +74,7 @@ As an example, this folder template will place all Liberated podcasts into a "Po
# Tag Formatters
**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Text Formatters
|Formatter|Description|Example Usage|Example Result|
@@ -81,12 +82,18 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
## Integer Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|# (a number)|Zero-pads the number|\<bitrate[4]\><br>\<series#[3]\><br>\<samplerate[6]\>|0128<br>001<br>044100|
**Text**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
## Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).

10
Scripts/targz2deb.sh → Scripts/targz2linuxbundle.sh Executable file → Normal file
View File

@@ -106,7 +106,10 @@ ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
# workaround until this file is moved to the user's home directory
touch /usr/lib/libation/appsettings.json
@@ -130,7 +133,10 @@ chmod +x "$FOLDER_DEBIAN/postinst"
echo "Creating .deb file..."
dpkg-deb -Zxz --build $FOLDER_MAIN
mkdir bundle
echo "moving to ./bundle/$FOLDER_MAIN.deb"
mv "$FOLDER_MAIN.deb" "./bundle/$FOLDER_MAIN.deb"
rm -r "$FOLDER_MAIN"
echo "Done!"

View File

@@ -0,0 +1,84 @@
#!/bin/bash
FILE=$1; shift
VERSION=$1; shift
if [ -z "$FILE" ]
then
echo "This script must be called with a the Libation macos bin zip file as an argument."
exit
fi
if [ ! -f "$FILE" ]
then
echo "The file \"$FILE\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$FILE" "$VERSION"
then
echo "This script must be called with a Libation version number that is present in the filename passed."
exit
fi
BUNDLE="Libation.app"
echo "Bundle dir: $BUNDLE"
if [[ -d "$BUNDLE" ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
fi
BUNDLE_CONTENTS="$BUNDLE/Contents"
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
BUNDLE_RESOURCES="$BUNDLE_CONTENTS/Resources"
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS="$BUNDLE_CONTENTS/MacOS"
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p "$BUNDLE_CONTENTS"
mkdir -p "$BUNDLE_RESOURCES"
mkdir -p "$BUNDLE_MACOS"
echo "Extracting $FILE to $BUNDLE_MACOS..."
tar -xzf ${FILE} -C ${BUNDLE_MACOS}
if [ $? -ne 0 ]
then echo "Error extracting ${FILE}"
exit
fi
echo "Copying icon..."
cp "$BUNDLE_MACOS/libation.icns" "$BUNDLE_RESOURCES/libation.icns"
echo "Copying Info.plist file..."
cp "$BUNDLE_MACOS/Info.plist" "$BUNDLE_CONTENTS/Info.plist"
echo "Set Libation version number..."
sed -i -e "s/VERSION_STRING/$VERSION/" "$BUNDLE_CONTENTS/Info.plist"
echo "deleting unneeded files.."
delfiles=("libmp3lame.x64.so" "ffmpegaac.x64.so" "libation.icns" "Info.plist")
for n in "${delfiles[@]}"; do rm "$BUNDLE_MACOS/$n"; done
echo "Creating app bundle: $BUNDLE-$VERSION.tar.gz"
tar -czvf "$BUNDLE-$VERSION.tar.gz" "$BUNDLE"
mkdir bundle
echo "moving to ./bundle/$BUNDLE-$VERSION.tar.gz"
mv "$BUNDLE-$VERSION.tar.gz" "./bundle/$BUNDLE-macOS-x64-$VERSION.tgz"
rm -r "$BUNDLE"
echo "Done!"

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="0.5.12" />
<PackageReference Include="AAXClean.Codecs" Version="0.5.14" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -25,9 +26,6 @@ namespace AppScaffolding
: value;
#region 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);
public static bool APPSETTINGS_TryGet(string key, out string value)
{
@@ -61,11 +59,7 @@ namespace AppScaffolding
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!APPSETTINGS_Json_Exists)
return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
JObject jObj;
try
@@ -88,7 +82,7 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion

View File

@@ -1,11 +1,11 @@
using System;
using NPOI.XWPF.UserModel;
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
{
public record UpgradeProperties
public partial record UpgradeProperties
{
private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)");
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
@@ -18,17 +18,10 @@ namespace AppScaffolding
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
Notes = stripMarkdownLinks(notes);
Notes = LinkStripRegex().Replace(notes, "$1");
}
private string stripMarkdownLinks(string body)
{
body = body.Replace(@"\", "");
var matches = linkstripper.Matches(body);
foreach (Match match in matches)
body = body.Replace(match.Groups[0].Value, match.Groups[1].Value);
return body;
}
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
private static partial Regex LinkStripRegex();
}
}

View File

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

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.2">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -20,7 +20,7 @@ namespace DataLayer
PartialDownload = 0x1000
}
public class UserDefinedItem
public partial class UserDefinedItem
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
@@ -51,18 +51,23 @@ namespace DataLayer
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
/// <summary>
/// only legal chars are letters numbers underscores and separating whitespace
///
/// technically, the only char.s which aren't easily supported are \ [ ]
/// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
/// it's easy to expand whitelist as needed
/// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
///
/// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
/// full list of characters which must be escaped:
/// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
/// </summary>
[GeneratedRegex(@"[^\w\d\s_]")]
private static partial Regex IllegalCharacterRegex();
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
return "";
@@ -73,9 +78,9 @@ namespace DataLayer
// assume a hyphen is supposed to be an underscore
.Replace("-", "_");
var unique = regex
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
var unique = IllegalCharacterRegex()
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
// split and remove excess spaces
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
// de-dup

View File

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

View File

@@ -17,7 +17,7 @@ namespace HangoverAvalonia.ViewModels
private void Load_databaseVM()
{
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)

View File

@@ -4,6 +4,7 @@
<OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AssemblyName>Hangover</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>hangover.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings>

View File

@@ -11,7 +11,6 @@ using System.Threading.Tasks;
using System.Collections.Generic;
using System.IO;
using ApplicationServices;
using Dinah.Core;
namespace LibationAvalonia
{
@@ -53,7 +52,7 @@ namespace LibationAvalonia
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
config.SetLibationFiles(defaultLibationFilesDir);
Configuration.SetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
{
@@ -86,7 +85,7 @@ namespace LibationAvalonia
// - error message, Exit()
if (setupDialog.IsNewUser)
{
setupDialog.Config.SetLibationFiles(Configuration.UserProfile);
Configuration.SetLibationFiles(Configuration.UserProfile);
ShowSettingsWindow(desktop, setupDialog.Config, OnSettingsCompleted);
}
else if (setupDialog.IsReturningUser)
@@ -178,7 +177,7 @@ namespace LibationAvalonia
private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config)
{
config.SetLibationFiles(libationFilesDialog.SelectedDirectory);
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);

View File

@@ -0,0 +1,30 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog"
Title="Locate Audiobooks"
WindowStartupLocation="CenterOwner"
Icon="/Assets/libation.ico">
<Grid Margin="5" ColumnDefinitions="*,Auto" RowDefinitions="Auto,*">
<TextBlock Grid.Column="0" Text="Found Audiobooks" />
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="IDs Found: " />
<TextBlock Text="{Binding FoundAsins}" />
</StackPanel>
<ListBox Margin="0,5,0,0" Grid.Row="1" Grid.ColumnSpan="2" Name="foundAudiobooksLB" Items="{Binding FoundFiles}" AutoScrollToSelectedItem="true">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Margin="0,0,10,0" Text="{Binding Item1}" />
<TextBlock Grid.Column="1" Text="{Binding Item2}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</Grid>
</Window>

View File

@@ -0,0 +1,115 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Platform.Storage.FileIO;
using DataLayer;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class LocateAudiobooksDialog : DialogWindow
{
private event EventHandler<FilePathCache.CacheEntry> FileFound;
private readonly CancellationTokenSource tokenSource = new();
private readonly List<string> foundAsins = new();
private readonly LocatedAudiobooksViewModel _viewModel;
public LocateAudiobooksDialog()
{
InitializeComponent();
DataContext = _viewModel = new();
this.RestoreSizeAndLocation(Configuration.Instance);
if (Design.IsDesignMode)
{
_viewModel.FoundFiles.Add(new("[0000001]", "Filename 1.m4b"));
_viewModel.FoundFiles.Add(new("[0000002]", "Filename 2.m4b"));
}
else
{
Opened += LocateAudiobooksDialog_Opened;
FileFound += LocateAudiobooks_FileFound;
Closing += LocateAudiobooksDialog_Closing;
}
}
private void LocateAudiobooksDialog_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
tokenSource.Cancel();
//If this dialog is closed before it's completed, Closing is fired
//once for the form closing and again for the MessageBox closing.
Closing -= LocateAudiobooksDialog_Closing;
this.SaveSizeAndLocation(Configuration.Instance);
}
private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e)
{
var newItem = new Tuple<string,string>($"[{e.Id}]", Path.GetFileName(e.Path));
_viewModel.FoundFiles.Add(newItem);
foundAudiobooksLB.SelectedItem = newItem;
if (!foundAsins.Any(asin => asin == e.Id))
{
foundAsins.Add(e.Id);
_viewModel.FoundAsins = foundAsins.Count;
}
}
private async void LocateAudiobooksDialog_Opened(object sender, EventArgs e)
{
var folderPicker = new FolderPickerOpenOptions
{
Title = "Select the folder to search for audiobooks",
AllowMultiple = false,
SuggestedStartLocation = new BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix)
};
var selectedFolder = await StorageProvider.OpenFolderPickerAsync(folderPicker);
if (selectedFolder.FirstOrDefault().TryGetUri(out var uri) is not true || !Directory.Exists(uri.LocalPath))
{
await CancelAndCloseAsync();
return;
}
using var context = DbContexts.GetContext();
await foreach (var book in AudioFileStorage.FindAudiobooksAsync(uri.LocalPath, tokenSource.Token))
{
try
{
FilePathCache.Insert(book);
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
FileFound?.Invoke(this, book);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
}
}
await MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks");
await SaveAndCloseAsync();
}
}
public class LocatedAudiobooksViewModel : ViewModelBase
{
private int _foundAsins = 0;
public AvaloniaList<Tuple<string, string>> FoundFiles { get; } = new();
public int FoundAsins { get => _foundAsins; set => this.RaiseAndSetIfChanged(ref _foundAsins, value); }
}
}

View File

@@ -35,13 +35,13 @@
</DockPanel.Styles>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button1Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/>
</Button>
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button2Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/>
</Button>
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button3Text}"/>
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/>
</Button>
</StackPanel>
</DockPanel>

View File

@@ -116,20 +116,6 @@
<ProjectReference Include="..\LibationUiBase\LibationUiBase.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.desktop">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="ZipExtractor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
<Target Name="SpicNSpan" AfterTargets="Clean">
<!-- Remove obj folder -->
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />

View File

@@ -154,6 +154,7 @@ Libation.
private static async Task<DialogResult> ShowCoreAsync(Window owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
{
owner = owner?.IsLoaded is true ? owner : null;
var dialog = await Dispatcher.UIThread.InvokeAsync(() => CreateMessageBox(owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition));
return await DisplayWindow(dialog, owner);

View File

@@ -2,6 +2,7 @@ using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using ApplicationServices;
using Avalonia;
@@ -13,8 +14,30 @@ namespace LibationAvalonia
{
static class Program
{
static void Main()
static void Main(string[] args)
{
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "hangover")
{
//Launch the Hangover app within the sandbox
//We can do this because we're already executing inside the sandbox.
//Any process created in the sandbox executes in the same sandbox.
//Unfortunately, all sandbox files are read/execute, so no writing!
Assembly asm = Assembly.GetExecutingAssembly();
string path = Path.GetDirectoryName(asm.Location);
Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
return;
}
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")
{
//Open a new Terminal in the sandbox
Assembly asm2 = Assembly.GetExecutingAssembly();
string libationProgramFiles = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location);
Process.Start("/System/Applications/Utilities/Terminal.app/Contents/MacOS/Terminal", $"\"{libationProgramFiles}\"");
return;
}
//***********************************************//
// //
// do not use Configuration before this line //

View File

@@ -44,11 +44,11 @@ namespace LibationAvalonia.ViewModels
private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); } }
public int QueuedCount { get => _queuedCount; private set { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); } }
public int ErrorCount { get => _errorCount; private set { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); } }
public string RunningTime { get => _runningTime; set { this.RaiseAndSetIfChanged(ref _runningTime, value); } }
public bool ProgressBarVisible { get => _progressBarVisible; set { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); } }
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 bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
public bool AnyErrors => ErrorCount > 0;
@@ -78,8 +78,11 @@ namespace LibationAvalonia.ViewModels
: _speedLimit > 1 ? 0.1m
: 0.01m;
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
Dispatcher.UIThread.Post(() =>
{
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
this.RaisePropertyChanged();
});
}
}
@@ -92,12 +95,12 @@ namespace LibationAvalonia.ViewModels
ErrorCount = errCount;
CompletedCount = completeCount;
this.RaisePropertyChanged(nameof(Progress));
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
}
private void Queue_QueuededCountChanged(object sender, int cueCount)
{
QueuedCount = cueCount;
this.RaisePropertyChanged(nameof(Progress));
Dispatcher.UIThread.Post(() => this.RaisePropertyChanged(nameof(Progress)));
}
public void WriteLine(string text)

View File

@@ -1,6 +1,7 @@
using ApplicationServices;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using FileManager;
using LibationFileManager;
using System;
using System.Linq;
@@ -20,14 +21,14 @@ namespace LibationAvalonia.Views
{
Title = "Where to export Library",
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books.PathWithoutPrefix),
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx",
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "xlsx" } },
new("CSV files (*.csv)") { Patterns = new[] { "csv" } },
new("JSON files (*.json)") { Patterns = new[] { "json" } },
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "*.xlsx" } },
new("CSV files (*.csv)") { Patterns = new[] { "*.csv" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
new("All files (*.*)") { Patterns = new[] { "*" } },
}
};
@@ -36,17 +37,17 @@ namespace LibationAvalonia.Views
if (selectedFile?.TryGetUri(out var uri) is not true) return;
var ext = System.IO.Path.GetExtension(uri.LocalPath);
var ext = FileUtility.GetStandardizedExtension(System.IO.Path.GetExtension(uri.LocalPath));
switch (ext)
{
case "xlsx": // xlsx
case ".xlsx": // xlsx
default:
LibraryExporter.ToXlsx(uri.LocalPath);
break;
case "csv": // csv
case ".csv": // csv
LibraryExporter.ToCsv(uri.LocalPath);
break;
case "json": // json
case ".json": // json
LibraryExporter.ToJson(uri.LocalPath);
break;
}

View File

@@ -1,6 +1,7 @@
using ApplicationServices;
using AudibleUtilities;
using Avalonia.Controls;
using LibationAvalonia.Dialogs;
using LibationFileManager;
using System;
using System.Collections.Generic;
@@ -77,5 +78,11 @@ namespace LibationAvalonia.Views
ex);
}
}
private async void locateAudiobooksToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var locateDialog = new LocateAudiobooksDialog();
await locateDialog.ShowDialog(this);
}
}
}

View File

@@ -1,5 +1,5 @@
using System;
using System.Linq;
using LibationFileManager;
using System;
namespace LibationAvalonia.Views
{
@@ -16,5 +16,17 @@ namespace LibationAvalonia.Views
public async void aboutToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await MessageBox.Show($"Libation {AppScaffolding.LibationScaffolding.Variety}{Environment.NewLine}Version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}");
public void launchHangoverToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
try
{
System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
}
catch(Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
}
}
}
}

View File

@@ -14,7 +14,7 @@ namespace LibationAvalonia.Views
Opened += async (_, _) => await checkForUpdates();
}
private async Task checkForUpdates()
private async Task checkForUpdates()
{
async Task<string> downloadUpdate(UpgradeProperties upgradeProperties)
{
@@ -26,7 +26,7 @@ namespace LibationAvalonia.Views
//Silently download the update in the background, save it to a temp file.
var zipFile = Path.GetTempFileName();
var zipFile = Path.Combine(Path.GetTempPath(), Path.GetFileName(upgradeProperties.ZipUrl));
try
{
System.Net.Http.HttpClient cli = new();
@@ -42,36 +42,6 @@ namespace LibationAvalonia.Views
return zipFile;
}
void runWindowsUpgrader(string zipFile)
{
var thisExe = Environment.ProcessPath;
var thisDir = Path.GetDirectoryName(thisExe);
var zipExtractor = Path.Combine(Path.GetTempPath(), "ZipExtractor.exe");
File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true);
var psi = new System.Diagnostics.ProcessStartInfo()
{
FileName = zipExtractor,
UseShellExecute = true,
Verb = "runas",
WindowStyle = System.Diagnostics.ProcessWindowStyle.Normal,
CreateNoWindow = true,
ArgumentList =
{
"--input",
zipFile,
"--output",
thisDir,
"--executable",
thisExe
}
};
System.Diagnostics.Process.Start(psi);
}
try
{
var upgradeProperties = await Task.Run(LibationScaffolding.GetLatestRelease);
@@ -83,26 +53,22 @@ namespace LibationAvalonia.Views
if (config.GetString(propertyName: ignoreUpdate) == upgradeProperties.LatestRelease.ToString())
return;
var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, Configuration.IsWindows).ShowDialog<DialogResult>(this);
var interop = InteropFactory.Create();
var notificationResult = await new UpgradeNotificationDialog(upgradeProperties, interop.CanUpdate).ShowDialog<DialogResult>(this);
if (notificationResult == DialogResult.Ignore)
config.SetString(upgradeProperties.LatestRelease.ToString(), ignoreUpdate);
if (notificationResult != DialogResult.OK || !Configuration.IsWindows) return;
if (notificationResult != DialogResult.OK) return;
//Download the update file in the background,
//then wire up installaion on window close.
string updateBundle = await downloadUpdate(upgradeProperties);
string zipFile = await downloadUpdate(upgradeProperties);
if (string.IsNullOrEmpty(updateBundle) || !File.Exists(updateBundle)) return;
if (string.IsNullOrEmpty(zipFile) || !File.Exists(zipFile))
return;
Closed += (_, _) =>
{
if (File.Exists(zipFile))
runWindowsUpgrader(zipFile);
};
//Install the update
interop.InstallUpdate(updateBundle);
}
catch (Exception ex)
{

View File

@@ -51,6 +51,9 @@
<MenuItem IsVisible="{Binding OneAccount}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeLibraryBooksToolStripMenuItem_Click" Header="_Remove Library Books" />
<MenuItem IsVisible="{Binding MultipleAccounts}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeAllAccountsToolStripMenuItem_Click" Header="_Remove Books from All Accounts" />
<MenuItem IsVisible="{Binding MultipleAccounts}" IsEnabled="{Binding RemoveMenuItemsEnabled}" Click="removeSomeAccountsToolStripMenuItem_Click" Header="_Remove Books from Some Accounts" />
<Separator />
<MenuItem Click="locateAudiobooksToolStripMenuItem_Click" Header="L_ocate Audiobooks" />
</MenuItem>
@@ -128,6 +131,8 @@
<MenuItem Click="accountsToolStripMenuItem_Click" Header="_Accounts..." />
<MenuItem Click="basicSettingsToolStripMenuItem_Click" Header="_Settings..." />
<Separator />
<MenuItem Click="launchHangoverToolStripMenuItem_Click" Header="Launch _Hangover" />
<Separator />
<MenuItem Click="aboutToolStripMenuItem_Click" Header="A_bout..." />
</MenuItem>
</Menu>

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
@@ -100,9 +101,9 @@ namespace LibationAvalonia.Views
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId);
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
locateFileMenuItem.Click += async (_, __) =>
{
try

View File

@@ -2,7 +2,11 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text.RegularExpressions;
using Dinah.Core;
using System.Threading.Tasks;
using System.Threading;
using FileManager;
namespace LibationFileManager
@@ -104,8 +108,14 @@ namespace LibationFileManager
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new();
private static EnumerationOptions enumerationOptions { get; } = new()
{
RecurseSubdirectories = true,
IgnoreInaccessible = true,
MatchCasing = MatchCasing.CaseInsensitive
};
protected override LongPath GetFilePathCustom(string productId)
protected override LongPath GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
@@ -122,5 +132,40 @@ namespace LibationFileManager
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public LongPath GetPath(string productId) => GetFilePath(productId);
public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken)
{
ArgumentValidator.EnsureNotNull(searchDirectory, nameof(searchDirectory));
foreach (LongPath path in Directory.EnumerateFiles(searchDirectory, "*.M4B", enumerationOptions))
{
if (cancellationToken.IsCancellationRequested)
yield break;
FilePathCache.CacheEntry audioFile = default;
try
{
using var fileStream = File.OpenRead(path);
var mp4File = await Task.Run(() => new AAXClean.Mp4File(fileStream), cancellationToken);
if (mp4File?.AppleTags?.Asin is not null)
audioFile = new FilePathCache.CacheEntry(mp4File.AppleTags.Asin, FileType.Audio, path);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error checking for asin in {@file}", path);
}
finally
{
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
if (audioFile is not null)
yield return audioFile;
}
}
}
}

View File

@@ -5,20 +5,22 @@ using FileManager;
using Newtonsoft.Json.Linq;
using Newtonsoft.Json;
using Serilog;
using Dinah.Core.Logging;
namespace LibationFileManager
{
public partial class Configuration
{
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), "appsettings.json");
private const string LIBATION_FILES_KEY = "LibationFiles";
public static string AppsettingsJsonFile { get; } = getOrCreateAppsettingsFile();
private const string LIBATION_FILES_KEY = "LibationFiles";
[Description("Location for storage of program-created files")]
public string LibationFiles
{
get
{
if (libationFilesPathCache is not null)
if (libationFilesPathCache is not null)
return libationFilesPathCache;
// FIRST: must write here before SettingsFilePath in next step reads cache
@@ -44,54 +46,93 @@ namespace LibationFileManager
private static string libationFilesPathCache { get; set; }
private string getLibationFilesSettingFromJson()
/// <summary>
/// Try to find appsettings.json in the following locations:
/// <list type="number">
/// <item>
/// <description>[App Directory]</description>
/// </item>
/// <item>
/// <description>%LocalAppData%\Libation</description>
/// </item>
/// <item>
/// <description>%AppData%\Libation</description>
/// </item>
/// <item>
/// <description>%Temp%\Libation</description>
/// </item>
/// </list>
///
/// If not found, try to create it in each of the same locations in-order until successful.
///
/// <para>This method must complete successfully for Libation to continue.</para>
/// </summary>
/// <returns>appsettings.json file path</returns>
/// <exception cref="ApplicationException">appsettings.json could not be found or created.</exception>
private static string getOrCreateAppsettingsFile()
{
const string appsettings_filename = "appsettings.json";
//Possible appsettings.json locations, in order of preference.
string[] possibleAppsettingsFiles = new[]
{
Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetExecutingAssembly().Location), appsettings_filename),
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation", appsettings_filename),
Path.Combine(UserProfile, appsettings_filename),
Path.Combine(Path.GetTempPath(), "Libation", appsettings_filename)
};
//Try to find and validate appsettings.json in each folder
foreach (var appsettingsFile in possibleAppsettingsFiles)
{
if (File.Exists(appsettingsFile))
{
try
{
var appSettings = JObject.Parse(File.ReadAllText(appsettingsFile));
if (appSettings.ContainsKey(LIBATION_FILES_KEY)
&& appSettings[LIBATION_FILES_KEY] is JValue jval
&& jval.Value is string settingsPath
&& !string.IsNullOrWhiteSpace(settingsPath))
return appsettingsFile;
}
catch { }
}
}
//Valid appsettings.json not found. Try to create it in each folder.
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented);
foreach (var appsettingsFile in possibleAppsettingsFiles)
{
try
{
File.WriteAllText(appsettingsFile, endingContents);
return appsettingsFile;
}
catch(Exception ex)
{
Log.Logger.TryLogError(ex, $"Failed to create {appsettingsFile}");
}
}
throw new ApplicationException($"Could not locate or create {appsettings_filename}");
}
private static string getLibationFilesSettingFromJson()
{
string startingContents = null;
try
{
if (File.Exists(APPSETTINGS_JSON))
{
startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingJObj = JObject.Parse(startingContents);
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
{
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
if (!string.IsNullOrWhiteSpace(startingValue))
return startingValue;
}
}
}
catch { }
// not found. write to file. read from file
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile.ToString() } }.ToString(Formatting.Indented);
if (startingContents != endingContents)
{
File.WriteAllText(APPSETTINGS_JSON, endingContents);
System.Threading.Thread.Sleep(100);
}
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
var jObjFinal = JObject.Parse(File.ReadAllText(AppsettingsJsonFile));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
return valueFinal;
}
public void SetLibationFiles(string directory)
public static void SetLibationFiles(string directory)
{
// ensure exists
if (!File.Exists(APPSETTINGS_JSON))
{
// getter creates new file, loads PersistentDictionary
var _ = LibationFiles;
System.Threading.Thread.Sleep(100);
}
libationFilesPathCache = null;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingContents = File.ReadAllText(AppsettingsJsonFile);
var jObj = JObject.Parse(startingContents);
jObj[LIBATION_FILES_KEY] = directory;
@@ -100,14 +141,17 @@ namespace LibationFileManager
if (startingContents == endingContents)
return;
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
try
{
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
}
// now it's set in the file again but no settings have moved yet
File.WriteAllText(AppsettingsJsonFile, endingContents);
Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, directory });
}
catch (IOException ex)
{
Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, directory });
}
}
}
}

View File

@@ -9,9 +9,7 @@ namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
{
public bool LibationSettingsAreValid
=> File.Exists(APPSETTINGS_JSON)
&& SettingsFileIsValid(SettingsFilePath);
public bool LibationSettingsAreValid => SettingsFileIsValid(SettingsFilePath);
public static bool SettingsFileIsValid(string settingsFile)
{

View File

@@ -86,7 +86,11 @@ namespace LibationFileManager
public static void Insert(string id, string path)
{
var type = FileTypes.GetFileTypeFromPath(path);
var entry = new CacheEntry(id, type, path);
Insert(new CacheEntry(id, type, path));
}
public static void Insert(CacheEntry entry)
{
cache.Add(entry);
Inserted?.Invoke(null, entry);
save();

View File

@@ -1,4 +1,5 @@
using System;
using System.Diagnostics;
namespace LibationFileManager
{
@@ -6,6 +7,8 @@ namespace LibationFileManager
{
void SetFolderIcon(string image, string directory);
void DeleteFolderIcon(string directory);
void CopyTextToClipboard(string text);
Process RunAsRoot(string exe, string args);
void InstallUpdate(string updateBundle);
bool CanUpdate { get; }
}
}

View File

@@ -6,6 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" />
<PackageReference Include="NameParserSharp" Version="1.5.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
</ItemGroup>

View File

@@ -0,0 +1,95 @@
using FileManager.NamingTemplate;
using NameParser;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
namespace LibationFileManager
{
internal partial class NameListFormat
{
public static string Formatter(ITemplateTag _, IEnumerable<string> names, string formatString)
{
var humanNames = names.Select(n => new HumanName(RemoveSuffix(n), Prefer.FirstOverPrefix));
var sortedNames = Sort(humanNames, formatString);
var nameFormatString = Format(formatString, defaultValue: "{T} {F} {M} {L} {S}");
var separatorString = Separator(formatString, defaultValue: ", ");
var maxNames = Max(formatString, defaultValue: humanNames.Count());
var formattedNames = string.Join(separatorString, sortedNames.Take(maxNames).Select(n => FormatName(n, nameFormatString)));
while (formattedNames.Contains(" "))
formattedNames = formattedNames.Replace(" ", " ");
return formattedNames;
}
private static string RemoveSuffix(string namesString)
{
namesString = namesString.Replace('', '\'').Replace(" - Ret.", ", Ret.");
int dashIndex = namesString.IndexOf(" - ");
return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim();
}
private static IEnumerable<HumanName> Sort(IEnumerable<HumanName> humanNames, string formatString)
{
var sortMatch = SortRegex().Match(formatString);
return
sortMatch.Success
? sortMatch.Groups[1].Value == "F" ? humanNames.OrderBy(n => n.First)
: sortMatch.Groups[1].Value == "M" ? humanNames.OrderBy(n => n.Middle)
: sortMatch.Groups[1].Value == "L" ? humanNames.OrderBy(n => n.Last)
: humanNames
: humanNames;
}
private static string Format(string formatString, string defaultValue)
{
var formatMatch = FormatRegex().Match(formatString);
return formatMatch.Success ? formatMatch.Groups[1].Value : defaultValue;
}
private static string Separator(string formatString, string defaultValue)
{
var separatorMatch = SeparatorRegex().Match(formatString);
return separatorMatch.Success ? separatorMatch.Groups[1].Value : defaultValue;
}
private static int Max(string formatString, int defaultValue)
{
var maxMatch = MaxRegex().Match(formatString);
return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : defaultValue;
}
private static string FormatName(HumanName humanName, string nameFormatString)
{
//Single-word names parse as first names. Use it as last name.
var lastName = string.IsNullOrWhiteSpace(humanName.Last) ? humanName.First : humanName.Last;
nameFormatString
= nameFormatString
.Replace("{T}", "{0}")
.Replace("{F}", "{1}")
.Replace("{M}", "{2}")
.Replace("{L}", "{3}")
.Replace("{S}", "{4}");
return string.Format(nameFormatString, humanName.Title, humanName.First, humanName.Middle, lastName, humanName.Suffix).Trim();
}
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
private static partial Regex SortRegex();
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, or {S} </summary>
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]})+.*?)\)")]
private static partial Regex FormatRegex();
/// <summary> Separator can be anything </summary>
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
private static partial Regex SeparatorRegex();
/// <summary> Max must have a 1 or 2-digit number </summary>
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
private static partial Regex MaxRegex();
}
}

View File

@@ -1,14 +1,18 @@
using System;
using System.Diagnostics;
namespace LibationFileManager
{
public class NullInteropFunctions : IInteropFunctions
{
public NullInteropFunctions() { }
public NullInteropFunctions() { }
public NullInteropFunctions(params object[] values) { }
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException();
}
public bool CanUpdate => throw new PlatformNotSupportedException();
public Process RunAsRoot(string exe, string args) => throw new PlatformNotSupportedException();
public void InstallUpdate(string updateBundle) => throw new PlatformNotSupportedException();
}
}

View File

@@ -6,6 +6,7 @@ using AaxDecrypter;
using Dinah.Core;
using FileManager;
using FileManager.NamingTemplate;
using NameParser;
namespace LibationFileManager
{
@@ -68,6 +69,9 @@ namespace LibationFileManager
Configuration.Instance.PropertyChanged +=
[PropertyChangeFilter(nameof(Configuration.ChapterTitleTemplate))]
(_, e) => _chapterTitle = GetTemplate<ChapterTitleTemplate>((string)e.NewValue);
HumanName.Suffixes.Add("ret");
HumanName.Titles.Add("professor");
}
#endregion
@@ -198,9 +202,9 @@ namespace LibationFileManager
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v },
{ TemplateTags.Title, lb => lb.Title },
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
{ TemplateTags.Author, lb => lb.AuthorNames },
{ TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter },
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor },
{ TemplateTags.Narrator, lb => lb.NarratorNames },
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter },
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator },
{ TemplateTags.Series, lb => lb.SeriesName },
{ TemplateTags.SeriesNumber, lb => lb.SeriesNumber },

View File

@@ -1 +0,0 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(LibationFileManager) + ".Tests")]

View File

@@ -5,7 +5,7 @@ using System.Text.RegularExpressions;
namespace LibationSearchEngine
{
internal static class LuceneRegex
internal static partial class LuceneRegex
{
#region pattern pieces
// negative lookbehind: cannot be preceeded by an escaping \
@@ -38,28 +38,32 @@ namespace LibationSearchEngine
private static string fieldPattern { get; } = NOT_ESCAPED + WORD_CAPTURE + FIELD_END;
public static Regex FieldRegex { get; } = new Regex(fieldPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
// 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 ] }
public static Regex NumbersRegex { get; } = new Regex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", 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>
/// <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; }
[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
@@ -95,5 +99,5 @@ namespace LibationSearchEngine
return regex;
}
}
}
}

View File

@@ -402,7 +402,7 @@ namespace LibationSearchEngine
private static string padNumbers(string searchString)
{
var matches = LuceneRegex
.NumbersRegex
.NumbersRegex()
.Matches(searchString)
.Cast<Match>()
.OrderByDescending(m => m.Index);
@@ -410,7 +410,7 @@ namespace LibationSearchEngine
foreach (var m in matches)
{
var replaceString = double.Parse(m.ToString()).ToLuceneString();
searchString = LuceneRegex.NumbersRegex.Replace(searchString, replaceString, 1, m.Index);
searchString = LuceneRegex.NumbersRegex().Replace(searchString, replaceString, 1, m.Index);
}
return searchString;

View File

@@ -0,0 +1,107 @@
namespace LibationWinForms.Dialogs
{
partial class LocateAudiobooksDialog
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.label1 = new System.Windows.Forms.Label();
this.foundAudiobooksLV = new System.Windows.Forms.ListView();
this.columnHeader1 = new System.Windows.Forms.ColumnHeader();
this.columnHeader2 = new System.Windows.Forms.ColumnHeader();
this.booksFoundLbl = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(12, 9);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(108, 15);
this.label1.TabIndex = 1;
this.label1.Text = "Found Audiobooks";
//
// foundAudiobooksLV
//
this.foundAudiobooksLV.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.foundAudiobooksLV.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
this.columnHeader1,
this.columnHeader2});
this.foundAudiobooksLV.FullRowSelect = true;
this.foundAudiobooksLV.Location = new System.Drawing.Point(12, 33);
this.foundAudiobooksLV.Name = "foundAudiobooksLV";
this.foundAudiobooksLV.Size = new System.Drawing.Size(321, 261);
this.foundAudiobooksLV.TabIndex = 2;
this.foundAudiobooksLV.UseCompatibleStateImageBehavior = false;
this.foundAudiobooksLV.View = System.Windows.Forms.View.Details;
//
// columnHeader1
//
this.columnHeader1.Text = "Book ID";
this.columnHeader1.Width = 85;
//
// columnHeader2
//
this.columnHeader2.Text = "Title";
//
// booksFoundLbl
//
this.booksFoundLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.booksFoundLbl.AutoSize = true;
this.booksFoundLbl.Location = new System.Drawing.Point(253, 9);
this.booksFoundLbl.Name = "booksFoundLbl";
this.booksFoundLbl.Size = new System.Drawing.Size(80, 15);
this.booksFoundLbl.TabIndex = 3;
this.booksFoundLbl.Text = "IDs Found: {0}";
this.booksFoundLbl.TextAlign = System.Drawing.ContentAlignment.TopRight;
//
// LocateAudiobooksDialog
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(345, 306);
this.Controls.Add(this.booksFoundLbl);
this.Controls.Add(this.foundAudiobooksLV);
this.Controls.Add(this.label1);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.SizableToolWindow;
this.Name = "LocateAudiobooksDialog";
this.Text = "Locate Audiobooks";
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label label1;
private System.Windows.Forms.ListView foundAudiobooksLV;
private System.Windows.Forms.ColumnHeader columnHeader1;
private System.Windows.Forms.ColumnHeader columnHeader2;
private System.Windows.Forms.Label booksFoundLbl;
}
}

View File

@@ -0,0 +1,98 @@
using ApplicationServices;
using DataLayer;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using System.Windows.Forms;
namespace LibationWinForms.Dialogs
{
public partial class LocateAudiobooksDialog : Form
{
private event EventHandler<FilePathCache.CacheEntry> FileFound;
private readonly CancellationTokenSource tokenSource = new();
private readonly List<string> foundAsins = new();
private readonly string labelFormatText;
public LocateAudiobooksDialog()
{
InitializeComponent();
labelFormatText = booksFoundLbl.Text;
setFoundBookCount(0);
this.SetLibationIcon();
this.RestoreSizeAndLocation(Configuration.Instance);
Shown += LocateAudiobooks_Shown;
FileFound += LocateAudiobooks_FileFound;
FormClosing += LocateAudiobooks_FormClosing;
}
private void setFoundBookCount(int count)
=> booksFoundLbl.Text = string.Format(labelFormatText, count);
private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e)
{
foundAudiobooksLV.Items
.Add(new ListViewItem(new string[] { $"[{e.Id}]", Path.GetFileName(e.Path) }))
.EnsureVisible();
foundAudiobooksLV.AutoResizeColumn(1, ColumnHeaderAutoResizeStyle.ColumnContent);
if (!foundAsins.Any(asin => asin == e.Id))
{
foundAsins.Add(e.Id);
setFoundBookCount(foundAsins.Count);
}
}
private void LocateAudiobooks_FormClosing(object sender, FormClosingEventArgs e)
{
tokenSource.Cancel();
this.SaveSizeAndLocation(Configuration.Instance);
}
private async void LocateAudiobooks_Shown(object sender, EventArgs e)
{
var fbd = new FolderBrowserDialog
{
Description = "Select the folder to search for audiobooks",
UseDescriptionForTitle = true,
InitialDirectory = Configuration.Instance.Books
};
if (fbd.ShowDialog() != DialogResult.OK || !Directory.Exists(fbd.SelectedPath))
{
Close();
return;
}
using var context = DbContexts.GetContext();
await foreach (var book in AudioFileStorage.FindAudiobooksAsync(fbd.SelectedPath, tokenSource.Token))
{
try
{
FilePathCache.Insert(book);
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
this.Invoke(FileFound, this, book);
}
catch(Exception ex)
{
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
}
}
MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks");
Close();
}
}
}

View File

@@ -0,0 +1,60 @@
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -35,7 +35,7 @@ namespace LibationWinForms.Dialogs
},
Configuration.KnownDirectories.UserProfile,
"Books");
booksSelectControl.SelectDirectory(config.Books);
booksSelectControl.SelectDirectory(config.Books.PathWithoutPrefix);
saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder;
betaOptInCbox.Checked = config.BetaOptIn;

View File

@@ -60,8 +60,12 @@
this.setBookDownloadedManualToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.setPdfDownloadedManualToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.setDownloadedAutoToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator3 = new System.Windows.Forms.ToolStripSeparator();
this.removeToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator4 = new System.Windows.Forms.ToolStripSeparator();
this.launchHangoverToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.locateAudiobooksToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.settingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.accountsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.basicSettingsToolStripMenuItem = new System.Windows.Forms.ToolStripMenuItem();
this.toolStripSeparator2 = new System.Windows.Forms.ToolStripSeparator();
@@ -149,7 +153,9 @@
this.scanLibraryToolStripMenuItem,
this.scanLibraryOfAllAccountsToolStripMenuItem,
this.scanLibraryOfSomeAccountsToolStripMenuItem,
this.removeLibraryBooksToolStripMenuItem});
this.removeLibraryBooksToolStripMenuItem,
this.toolStripSeparator3,
this.locateAudiobooksToolStripMenuItem});
this.importToolStripMenuItem.Name = "importToolStripMenuItem";
this.importToolStripMenuItem.Size = new System.Drawing.Size(55, 20);
this.importToolStripMenuItem.Text = "&Import";
@@ -374,6 +380,8 @@
this.settingsToolStripMenuItem.DropDownItems.AddRange(new System.Windows.Forms.ToolStripItem[] {
this.accountsToolStripMenuItem,
this.basicSettingsToolStripMenuItem,
this.toolStripSeparator4,
this.launchHangoverToolStripMenuItem,
this.toolStripSeparator2,
this.aboutToolStripMenuItem});
this.settingsToolStripMenuItem.Name = "settingsToolStripMenuItem";
@@ -560,10 +568,29 @@
this.processBookQueue1.Name = "processBookQueue1";
this.processBookQueue1.Size = new System.Drawing.Size(430, 640);
this.processBookQueue1.TabIndex = 0;
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
//
// locateAudiobooksToolStripMenuItem
//
this.locateAudiobooksToolStripMenuItem.Name = "locateAudiobooksToolStripMenuItem";
this.locateAudiobooksToolStripMenuItem.Size = new System.Drawing.Size(247, 22);
this.locateAudiobooksToolStripMenuItem.Text = "L&ocate Audiobooks";
this.locateAudiobooksToolStripMenuItem.Click += new System.EventHandler(this.locateAudiobooksToolStripMenuItem_Click);
//
// launchHangoverToolStripMenuItem
//
this.launchHangoverToolStripMenuItem.Name = "launchHangoverToolStripMenuItem";
this.launchHangoverToolStripMenuItem.Size = new System.Drawing.Size(247, 22);
this.launchHangoverToolStripMenuItem.Text = "Launch &Hangover";
this.launchHangoverToolStripMenuItem.Click += new System.EventHandler(this.launchHangoverToolStripMenuItem_Click);
//
// toolStripSeparator3
//
this.toolStripSeparator3.Name = "toolStripSeparator3";
this.toolStripSeparator3.Size = new System.Drawing.Size(244, 6);
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(1463, 640);
this.Controls.Add(this.splitContainer1);
@@ -630,6 +657,10 @@
private System.Windows.Forms.ToolStripMenuItem setBookDownloadedManualToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem setDownloadedAutoToolStripMenuItem;
private System.Windows.Forms.ToolStripMenuItem removeToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator3;
private System.Windows.Forms.ToolStripMenuItem locateAudiobooksToolStripMenuItem;
private System.Windows.Forms.ToolStripSeparator toolStripSeparator4;
private System.Windows.Forms.ToolStripMenuItem launchHangoverToolStripMenuItem;
private LibationWinForms.FormattableToolStripMenuItem liberateVisibleToolStripMenuItem_LiberateMenu;
private System.Windows.Forms.SplitContainer splitContainer1;
private LibationWinForms.ProcessQueue.ProcessQueueControl processBookQueue1;

View File

@@ -89,5 +89,10 @@ namespace LibationWinForms
ex);
}
}
private void locateAudiobooksToolStripMenuItem_Click(object sender, EventArgs e)
{
new LocateAudiobooksDialog().ShowDialog();
}
}
}

View File

@@ -14,5 +14,17 @@ namespace LibationWinForms
private void aboutToolStripMenuItem_Click(object sender, EventArgs e)
=> MessageBox.Show($"Libation {AppScaffolding.LibationScaffolding.Variety}{Environment.NewLine}Version {AppScaffolding.LibationScaffolding.BuildVersion}", $"Libation v{AppScaffolding.LibationScaffolding.BuildVersion}");
}
private void launchHangoverToolStripMenuItem_Click(object sender, EventArgs e)
{
try
{
System.Diagnostics.Process.Start("Hangover.exe");
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
}
}
}
}

View File

@@ -7,6 +7,7 @@ using ApplicationServices;
using Dinah.Core;
using Dinah.Core.Threading;
using LibationFileManager;
using LibationWinForms.Dialogs;
namespace LibationWinForms
{

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Data;
using System.Drawing;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using DataLayer;
@@ -122,7 +123,7 @@ namespace LibationWinForms.GridView
{
var dgv = (DataGridView)sender;
var text = dgv[e.ColumnIndex, e.RowIndex].FormattedValue.ToString();
InteropFactory.Create().CopyTextToClipboard(text);
Clipboard.SetDataObject(text, false, 5, 150);
}
catch { }
});
@@ -152,7 +153,7 @@ namespace LibationWinForms.GridView
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
var removeMenuItem = new ToolStripMenuItem() { Text = "&Remove from library" };
removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId);
removeMenuItem.Click += async (_, __) => await Task.Run(() => LibraryCommands.RemoveBook(entry.AudibleProductId));
var locateFileMenuItem = new ToolStripMenuItem() { Text = "&Locate file..." };
locateFileMenuItem.Click += (_, __) =>

View File

@@ -45,7 +45,7 @@
<ItemGroup>
<PackageReference Include="Autoupdater.NET.Official" Version="1.7.6" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.1.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -92,7 +92,7 @@ namespace LibationWinForms
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
config.SetLibationFiles(defaultLibationFilesDir);
Configuration.SetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
return;
@@ -112,7 +112,7 @@ namespace LibationWinForms
}
if (setupDialog.IsNewUser)
config.SetLibationFiles(defaultLibationFilesDir);
Configuration.SetLibationFiles(defaultLibationFilesDir);
else if (setupDialog.IsReturningUser)
{
var libationFilesDialog = new LibationFilesDialog();
@@ -123,7 +123,7 @@ namespace LibationWinForms
return;
}
config.SetLibationFiles(libationFilesDialog.SelectedDirectory);
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
return;

View File

@@ -34,4 +34,28 @@
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Properties\Resources.Designer.cs">
<DesignTime>True</DesignTime>
<AutoGen>True</AutoGen>
<DependentUpon>Resources.resx</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<EmbeddedResource Update="Properties\Resources.resx">
<Generator>ResXFileCodeGenerator</Generator>
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
</EmbeddedResource>
</ItemGroup>
<ItemGroup>
<None Update="glass-with-glow_256.svg">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="Libation.desktop">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,14 +1,73 @@
using LibationFileManager;
using System.Diagnostics;
namespace LinuxConfigApp
{
internal class LinuxInterop : IInteropFunctions
{
public LinuxInterop() { }
//Different terminal apps possibly installed on a linux system
// [0] console executable
// [1] argument to set the concole's title
// [2] argument to pass a command to be executed to the terminal
static readonly string[][] consoleCommands =
{
new[] {"konsole", "--title", "-e"},
new[] {"gnome-terminal", "--title", "--"},
new[] {"mate-terminal", "--title", "-x"},
new[] {"xterm", "-T", "-e"},
};
public LinuxInterop() { }
public LinuxInterop(params object[] values) { }
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException();
}
//only run the audo updater is the current app was installed from the
//.deb package. Try to detect this by checking if the symlink exists.
public bool CanUpdate => Directory.Exists("/usr/lib/libation");
public void InstallUpdate(string updateBundle)
{
RunAsRoot("apt", $"install '{updateBundle}'");
}
public Process RunAsRoot(string exe, string args)
{
//cribbed this script from VirtualBox's guest additions installer.
//It's designed to launch the system's gui superuser password
//prompt across multiple distributions and desktop environments.
const string runasroot = "/tmp/runasroot.sh";
File.WriteAllBytes(runasroot, Properties.Resources.runasroot);
string command = $"{exe ?? ""} {args ?? ""}".Trim();
foreach (var console in consoleCommands)
{
ProcessStartInfo psi = new()
{
FileName = console[0],
UseShellExecute = false,
ArgumentList =
{
console[1],
$"Running '{exe}' as root",
console[2],
"/bin/sh",
runasroot,
"Installing libation.deb",
command,
$"Please run '{command}' manually"
}
};
try
{
return Process.Start(psi);
}
catch { }
}
return null;
}
}
}

View File

@@ -0,0 +1,73 @@
//------------------------------------------------------------------------------
// <auto-generated>
// This code was generated by a tool.
// Runtime Version:4.0.30319.42000
//
// Changes to this file may cause incorrect behavior and will be lost if
// the code is regenerated.
// </auto-generated>
//------------------------------------------------------------------------------
namespace LinuxConfigApp.Properties {
using System;
/// <summary>
/// A strongly-typed resource class, for looking up localized strings, etc.
/// </summary>
// This class was auto-generated by the StronglyTypedResourceBuilder
// class via a tool like ResGen or Visual Studio.
// To add or remove a member, edit your .ResX file then rerun ResGen
// with the /str option, or rebuild your VS project.
[global::System.CodeDom.Compiler.GeneratedCodeAttribute("System.Resources.Tools.StronglyTypedResourceBuilder", "17.0.0.0")]
[global::System.Diagnostics.DebuggerNonUserCodeAttribute()]
[global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()]
internal class Resources {
private static global::System.Resources.ResourceManager resourceMan;
private static global::System.Globalization.CultureInfo resourceCulture;
[global::System.Diagnostics.CodeAnalysis.SuppressMessageAttribute("Microsoft.Performance", "CA1811:AvoidUncalledPrivateCode")]
internal Resources() {
}
/// <summary>
/// Returns the cached ResourceManager instance used by this class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Resources.ResourceManager ResourceManager {
get {
if (object.ReferenceEquals(resourceMan, null)) {
global::System.Resources.ResourceManager temp = new global::System.Resources.ResourceManager("LinuxConfigApp.Properties.Resources", typeof(Resources).Assembly);
resourceMan = temp;
}
return resourceMan;
}
}
/// <summary>
/// Overrides the current thread's CurrentUICulture property for all
/// resource lookups using this strongly typed resource class.
/// </summary>
[global::System.ComponentModel.EditorBrowsableAttribute(global::System.ComponentModel.EditorBrowsableState.Advanced)]
internal static global::System.Globalization.CultureInfo Culture {
get {
return resourceCulture;
}
set {
resourceCulture = value;
}
}
/// <summary>
/// Looks up a localized resource of type System.Byte[].
/// </summary>
internal static byte[] runasroot {
get {
object obj = ResourceManager.GetObject("runasroot", resourceCulture);
return ((byte[])(obj));
}
}
}
}

View File

@@ -0,0 +1,124 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<assembly alias="System.Windows.Forms" name="System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089" />
<data name="runasroot" type="System.Resources.ResXFileRef, System.Windows.Forms">
<value>..\Resources\runasroot.sh;System.Byte[], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</data>
</root>

View File

@@ -0,0 +1,188 @@
#!/bin/sh
# $Id: runasroot.sh 153224 2022-08-22 17:43:14Z klaus $
## @file
# VirtualBox privileged execution helper script for Linux and Solaris
#
#
# Copyright (C) 2009-2022 Oracle and/or its affiliates.
#
# This file is part of VirtualBox base platform packages, as
# available from https://www.virtualbox.org.
#
# This program is free software; you can redistribute it and/or
# modify it under the terms of the GNU General Public License
# as published by the Free Software Foundation, in version 3 of the
# License.
#
# This program is distributed in the hope that it will be useful, but
# WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
# General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program; if not, see <https://www.gnu.org/licenses>.
#
# SPDX-License-Identifier: GPL-3.0-only
#
# Deal with differing "which" semantics
mywhich() {
which "$1" 2>/dev/null | grep -v "no $1"
}
# Get the name and execute switch for a useful terminal emulator
#
# Sets $gxtpath to the emulator path or empty
# Sets $gxttitle to the "title" switch for that emulator
# Sets $gxtexec to the "execute" switch for that emulator
# May clobber $gtx*
# Calls mywhich
getxterm() {
# gnome-terminal uses -e differently to other emulators
for gxti in "konsole --title -e" "gnome-terminal --title -x" "xterm -T -e"; do
set $gxti
gxtpath="`mywhich $1`"
case "$gxtpath" in ?*)
gxttitle=$2
gxtexec=$3
return
;;
esac
done
}
# Quotes its argument by inserting '\' in front of every character save
# for 'A-Za-z0-9/'. Prints the result to stdout.
quotify() {
echo "$1" | sed -e 's/\([^a-zA-Z0-9/]\)/\\\1/g'
}
ostype=`uname -s`
if test "$ostype" != "Linux" && test "$ostype" != "SunOS" ; then
echo "Linux/Solaris not detected."
exit 1
fi
HAS_TERMINAL=""
case "$1" in "--has-terminal")
shift
HAS_TERMINAL="yes"
;;
esac
case "$#" in "2"|"3")
;;
*)
echo "Usage: `basename $0` DESCRIPTION COMMAND [ADVICE]" >&2
echo >&2
echo "Attempt to execute COMMAND with root privileges, displaying DESCRIPTION if" >&2
echo "possible and displaying ADVICE if possible if no su(1)-like tool is available." >&2
exit 1
;;
esac
DESCRIPTION=$1
COMMAND=$2
ADVICE=$3
PATH=$PATH:/bin:/usr/bin:/usr/local/bin:/sbin:/usr/sbin:/usr/local/sbin:/usr/X11/bin
case "$ostype" in SunOS)
PATH=$PATH:/usr/sfw/bin:/usr/gnu/bin:/usr/xpg4/bin:/usr/xpg6/bin:/usr/openwin/bin:/usr/ucb
GKSU_SWITCHES="-au root"
;;
*)
GKSU_SWITCHES=""
;;
esac
case "$HAS_TERMINAL" in "")
case "$DISPLAY" in ?*)
KDESUDO="`mywhich kdesudo`"
case "$KDESUDO" in ?*)
eval "`quotify "$KDESUDO"` --comment `quotify "$DESCRIPTION"` -- $COMMAND"
exit
;;
esac
KDESU="`mywhich kdesu`"
case "$KDESU" in ?*)
"$KDESU" -c "$COMMAND"
exit
;;
esac
GKSU="`mywhich gksu`"
case "$GKSU" in ?*)
# Older gksu does not grok --description nor '--' and multiple args.
# @todo which versions do?
# "$GKSU" --description "$DESCRIPTION" -- "$@"
# Note that $GKSU_SWITCHES is NOT quoted in the following
"$GKSU" $GKSU_SWITCHES "$COMMAND"
exit
;;
esac
;;
esac # $DISPLAY
;;
esac # ! $HAS_TERMINAL
# pkexec may work for ssh console sessions as well if the right agents
# are installed. However it is very generic and does not allow for any
# custom messages. Thus it comes after gksu.
## @todo should we insist on either a display or a terminal?
# case "$DISPLAY$HAS_TERMINAL" in ?*)
PKEXEC="`mywhich pkexec`"
case "$PKEXEC" in ?*)
eval "\"$PKEXEC\" $COMMAND"
exit
;;
esac
# ;;S
#esac
case "$HAS_TERMINAL" in ?*)
USE_SUDO=
grep -q Ubuntu /etc/lsb-release 2>/dev/null && USE_SUDO=true
# On Ubuntu we need sudo instead of su. Assume this works, and is only
# needed for Ubuntu until proven wrong.
case $USE_SUDO in true)
SUDO_COMMAND="`quotify "$SUDO"` -- $COMMAND"
eval "$SUDO_COMMAND"
exit
;;
esac
SU="`mywhich su`"
case "$SU" in ?*)
"$SU" - root -c "$COMMAND"
exit
;;
esac
;;
esac
# The ultimate fallback is running 'su -' within an xterm. We use the
# title of the xterm to tell what is going on.
case "$DISPLAY" in ?*)
SU="`mywhich su`"
case "$SU" in ?*)
getxterm
case "$gxtpath" in ?*)
"$gxtpath" "$gxttitle" "$DESCRIPTION - su" "$gxtexec" su - root -c "$COMMAND"
exit
;;
esac
esac
esac # $DISPLAY
# Failure...
case "$DISPLAY" in ?*)
echo "Unable to locate 'pkexec', 'gksu' or 'su+xterm'. $ADVICE" >&2
;;
*)
echo "Unable to locate 'pkexec'. $ADVICE" >&2
;;
esac
exit 1

View File

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleExecutable</key>
<string>Libation</string>
<key>CFBundleName</key>
<string>Libation</string>
<key>CFBundleIdentifier</key>
<string>org.libation.macos</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>CFBundleIconFile</key>
<string>libation.icns</string>
<key>CFBundleVersion</key>
<string>VERSION_STRING</string>
<key>com.apple.security.app-sandbox</key>
<false/>
<key>com.apple.security.cs.allow-jit</key>
<true/>
<key>com.apple.security.cs.allow-unsigned-executable-memory</key>
<true/>
<key>com.apple.security.cs.disable-library-validation</key>
<true/>
<key>com.apple.security.cs.disable-executable-page-protection</key>
<true/>
<key>com.apple.security.automation.apple-events</key>
<true/>
</dict>
</plist>

View File

@@ -34,4 +34,13 @@
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="Info.plist">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="libation.icns">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,14 +1,78 @@
using LibationFileManager;
using System.Diagnostics;
namespace MacOSConfigApp
{
internal class MacOSInterop : IInteropFunctions
{
public MacOSInterop() { }
{
private const string AppPath = "/Applications/Libation.app";
public MacOSInterop() { }
public MacOSInterop(params object[] values) { }
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
public void CopyTextToClipboard(string text) => throw new PlatformNotSupportedException();
}
//I haven't figured out how to find the app bundle's directory from within
//the running process, so don't update unless it's "installed" in /Applications
public bool CanUpdate => Directory.Exists(AppPath);
public void InstallUpdate(string updateBundle)
{
Serilog.Log.Information($"Extracting update bundle to {AppPath}");
//tar wil overwrite existing without elevated privileges
Process.Start("tar", $"-xzf \"{updateBundle}\" -C \"/Applications\"").WaitForExit();
//For now, it seems like this step is unnecessary. We can overwrite and
//run Libation without needing to re-add the exception. This is insurance.
RunAsRoot(null, $"""
sudo spctl --master-disable
sudo spctl --add --label 'Libation' {AppPath}
open {AppPath}
sudo spctl --master-enable
""");
}
//Using osascript -e '[script]' works from the terminal, but I haven't figured
//out the syntax for it to work from create_process, so write to stdin instead.
public Process RunAsRoot(string _, string command)
{
const string osascript = "osascript";
var fullCommand = $"do shell script \"{command}\" with administrator privileges";
var psi = new ProcessStartInfo()
{
FileName = osascript,
UseShellExecute = false,
Arguments = "-",
RedirectStandardError= true,
RedirectStandardOutput= true,
RedirectStandardInput= true,
};
Serilog.Log.Logger.Information($"running {osascript} as root: {{script}}", fullCommand);
var proc = Process.Start(psi);
proc.ErrorDataReceived += Proc_ErrorDataReceived;
proc.OutputDataReceived += Proc_OutputDataReceived;
proc.BeginErrorReadLine();
proc.BeginOutputReadLine();
proc.StandardInput.WriteLine(fullCommand);
proc.StandardInput.Close();
return proc;
}
private void Proc_OutputDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data != null)
Serilog.Log.Logger.Information("stderr: {data}", e.Data);
}
private void Proc_ErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (e.Data!= null)
Serilog.Log.Logger.Information("stderr: {data}", e.Data);
}
}
}

View File

Binary file not shown.

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
@@ -35,8 +36,31 @@ namespace WindowsConfigApp
public void DeleteFolderIcon(string directory)
=> new DirectoryInfo(directory)?.DeleteIcon();
public bool CanUpdate => true;
public void InstallUpdate(string updateBundle)
{
var thisExe = Environment.ProcessPath;
var thisDir = Path.GetDirectoryName(thisExe);
var zipExtractor = Path.Combine(Path.GetTempPath(), "ZipExtractor.exe");
public void CopyTextToClipboard(string text)
=> Clipboard.SetText(text);
File.Copy("ZipExtractor.exe", zipExtractor, overwrite: true);
RunAsRoot(zipExtractor, $"--input \"{updateBundle}\" --output \"{thisDir}\" --executable \"{thisExe}\"");
}
public Process RunAsRoot(string exe, string args)
{
var psi = new ProcessStartInfo()
{
FileName = exe,
UseShellExecute = true,
Verb = "runas",
WindowStyle = ProcessWindowStyle.Normal,
CreateNoWindow = true,
Arguments = args
};
return Process.Start(psi);
}
}
}

View File

@@ -33,11 +33,17 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.1.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\AppScaffolding\AppScaffolding.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="ZipExtractor.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
</ItemGroup>
</Project>

View File

@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />

View File

@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />

View File

@@ -238,6 +238,89 @@ namespace TemplatesTests
.Should().Be(expected);
}
[TestMethod]
[DataRow("Bruce Bueno de Mesquita", "Title=, First=Bruce, Middle=Bueno Last=de Mesquita, Suffix=")]
[DataRow("Ramon de Ocampo", "Title=, First=Ramon, Middle= Last=de Ocampo, Suffix=")]
[DataRow("Ramon De Ocampo", "Title=, First=Ramon, Middle= Last=De Ocampo, Suffix=")]
[DataRow("Jennifer Van Dyck", "Title=, First=Jennifer, Middle= Last=Van Dyck, Suffix=")]
[DataRow("Carla Naumburg PhD", "Title=, First=Carla, Middle= Last=Naumburg, Suffix=PhD")]
[DataRow("Doug Stanhope and Friends", "Title=, First=Doug, Middle= Last=Stanhope and Friends, Suffix=")]
[DataRow("Tamara Lovatt-Smith", "Title=, First=Tamara, Middle= Last=Lovatt-Smith, Suffix=")]
[DataRow("Common", "Title=, First=Common, Middle= Last=Common, Suffix=")]
[DataRow("Doug Tisdale Jr.", "Title=, First=Doug, Middle= Last=Tisdale, Suffix=Jr")]
[DataRow("Robert S. Mueller III", "Title=, First=Robert, Middle=S. Last=Mueller, Suffix=III")]
[DataRow("Frank T Vertosick Jr. MD", "Title=, First=Frank, Middle=T Last=Vertosick, Suffix=Jr. MD")]
[DataRow("The Arabian Nights", "Title=, First=The Arabian, Middle= Last=Nights, Suffix=")]
[DataRow("The Great Courses", "Title=, First=The Great, Middle= Last=Courses, Suffix=")]
[DataRow("The Laurie Berkner Band", "Title=, First=The Laurie, Middle=Berkner Last=Band, Suffix=")]
[DataRow("Committee on Foreign Affairs", "Title=, First=Committee, Middle=on Last=Foreign Affairs, Suffix=")]
[DataRow("House Permanent Select Committee on Intelligence", "Title=, First=House, Middle=Permanent Select Committee on Last=Intelligence, Suffix=")]
[DataRow("Professor David K. Johnson PhD University of Oklahoma", "Title=Professor, First=David, Middle=K. Johnson PhD Last=University of Oklahoma, Suffix=")]
[DataRow("Festival of the Spoken Nerd", "Title=, First=Festival of the Spoken, Middle= Last=Nerd, Suffix=")]
[DataRow("Audible Original", "Title=, First=Audible, Middle= Last=Original, Suffix=")]
[DataRow("Audible Originals", "Title=, First=Audible, Middle= Last=Originals, Suffix=")]
[DataRow("Patrick O'Brian", "Title=, First=Patrick, Middle= Last=O'Brian, Suffix=")]
[DataRow("Patrick OConnell", "Title=, First=Patrick, Middle= Last=O'Connell, Suffix=")]
[DataRow("L.E. Modesitt", "Title=, First=L.E., Middle= Last=Modesitt, Suffix=")]
[DataRow("L. E. Modesitt Jr.", "Title=, First=L., Middle=E. Last=Modesitt, Suffix=Jr")]
[DataRow("LE Modesitt, Jr.", "Title=, First=LE, Middle= Last=Modesitt, Suffix=Jr")]
[DataRow("Marine Le Pen", "Title=, First=Marine, Middle= Last=Le Pen, Suffix=")]
[DataRow("L. Sprague de Camp", "Title=, First=L., Middle=Sprague Last=de Camp, Suffix=")]
[DataRow("Lt. Col. - Ret. Douglas L. Bland", "Title=, First=Ret., Middle=Douglas L. Bland Last=Lt. Col., Suffix=")]
[DataRow("Col. Lee Ellis - Ret. - foreword", "Title=Col., First=Lee, Middle= Last=Ellis, Suffix=Ret")]
public void NameFormat_unusual(string author, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Authors = new List<string> { author };
Templates.TryGetTemplate<Templates.FileTemplate>("<author[format(Title={T}, First={F}, Middle={M} Last={L}, Suffix={S})]>", out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(bookDto, "", "", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[TestMethod]
[DataRow("<author>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[sort(F)]>", "Charles E. Gannon, Christopher John Fetherolf, Jill Conner Browne, Jon Bon Jovi, Lucy Maud Montgomery, Paul Van Doren")]
[DataRow("<author[sort(L)]>", "Jon Bon Jovi, Jill Conner Browne, Christopher John Fetherolf, Charles E. Gannon, Lucy Maud Montgomery, Paul Van Doren")]
[DataRow("<author[sort(M)]>", "Jon Bon Jovi, Paul Van Doren, Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery")]
[DataRow("<author[sort(f)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[sort(m)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[sort(l)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author [ max( 1 ) ]>", "Jill Conner Browne")]
[DataRow("<author[max(2)]>", "Jill Conner Browne, Charles E. Gannon")]
[DataRow("<author[max(3)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf")]
[DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul")]
[DataRow("<author[format({f}, {l})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
[DataRow("<author[format(First={F}, Last={L})]>", "First=Jill, Last=Browne, First=Charles, Last=Gannon, First=Christopher, Last=Fetherolf, First=Lucy, Last=Montgomery, First=Jon, Last=Bon Jovi, First=Paul, Last=Van Doren")]
[DataRow("<author[format({L}, {F}) separator( - ) max(3)]>", "Browne, Jill - Gannon, Charles - Fetherolf, Christopher")]
[DataRow("<author[sort(F) max(2) separator(; ) format({F})]>", "Charles; Christopher")]
[DataRow("<author[sort(L) max(2) separator(; ) format({L})]>", "Bon Jovi; Browne")]
//Jon Bon Jovi and Paul Van Doren don't have middle names, so they are sorted to the top.
//Since only the middle names of the first 2 names are to be displayed, the name string is empty.
[DataRow("<author[sort(M) max(2) separator(; ) format({M})]>", ";")]
public void NameFormat_formatters(string template, string expected)
{
var bookDto = GetLibraryBook();
bookDto.Authors = new List<string>
{
"Jill Conner Browne",
"Charles E. Gannon",
"Christopher John Fetherolf",
"Lucy Maud Montgomery",
"Jon Bon Jovi",
"Paul Van Doren"
};
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
fileTemplate
.GetFilename(bookDto, "", "", Replacements)
.PathWithoutPrefix
.Should().Be(expected);
}
[TestMethod]
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
[DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)]

View File

@@ -7,7 +7,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="FluentAssertions" Version="6.9.0" />
<PackageReference Include="FluentAssertions" Version="6.10.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
<PackageReference Include="Moq" Version="4.18.4" />
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />