Compare commits

...

55 Commits

Author SHA1 Message Date
Robert
b72e5039b1 incr ver 2025-11-24 09:48:22 -05:00
rmcrackan
e992b49da2 Merge pull request #1451 from Mbucari/master
Add more liberation logging
2025-11-24 09:14:23 -05:00
MBucari
74afbbf581 Add more liberation logging 2025-11-23 23:40:23 -07:00
rmcrackan
d82ffe1467 Merge pull request #1444 from Mbucari/master
Use C# 14 `field` and extension members.
2025-11-23 22:44:57 -05:00
Mbucari
8a84a083d1 Fix removed field 2025-11-23 20:38:20 -07:00
Mbucari
04827f81da Tweak in-app web browser login
- Use private browsing mode
- Use the Android User-Agent
- Use initial signin cookies
2025-11-23 20:35:04 -07:00
Mbucari
805f42b1cc Add warnings for inaccessable InProgress directory (#1446) 2025-11-22 15:29:54 -07:00
Mbucari
b9a1709284 Use release version of EntityFrameworkCore.PostgreSQL 2025-11-22 11:46:27 -07:00
Michael Bucari-Tovo
b0a40e12b7 Create some extension members
Trying out .NET 10s extension members with some Book extension properties.
2025-11-21 12:31:50 -07:00
Michael Bucari-Tovo
dfbc5ec9db Use the new field keyword where appropriate. 2025-11-21 11:50:07 -07:00
rmcrackan
649ef5f864 Merge pull request #1441 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-5
Bump apple-actions/import-codesign-certs from 3 to 5
2025-11-21 11:19:27 -05:00
rmcrackan
4345bf2ee2 Merge pull request #1442 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-11-21 11:19:09 -05:00
rmcrackan
441d430dea Merge pull request #1439 from Mbucari/master
Update to .NET 10
2025-11-21 09:15:00 -05:00
dependabot[bot]
85ee0bcddb Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:06:00 +00:00
dependabot[bot]
0f1fc0f11d Bump apple-actions/import-codesign-certs from 3 to 5
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 5.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v3...v5)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:05:53 +00:00
MBucari
75aa17df11 Fix winforms STAThread on main 2025-11-20 22:35:13 -07:00
MBucari
913019cdfd Revert hack workaround for DataGridView error 2025-11-20 22:19:43 -07:00
MBucari
a55da5f187 Refactor DbContext access and disposal
- Remove instance queue. This is a database, after all, and is designed to be accessed and written to concurrently
- Reduce the number of calls to DbContexts.Create()
- Ensure that no LibationContext remains open across an await boundary. Multithread context access is the most likely culprit for past issues.
- Make all Update UserDefinedItem methods asynchronous.
2025-11-20 22:15:54 -07:00
MBucari
f9ac0253fb Improve import performance on large batches 2025-11-20 21:37:13 -07:00
MBucari
fde78f4167 Update to .NET 10
- Update all project runtime targets
- Update all dependencies
  - NOTE: Using Npgsql.EntityFrameworkCore.PostgreSQL RTM build from MyGet
- Delete unused pubxml files (they were made redundant by recent workflow changes)
- Replace Libation.sln with Libation.slnx
2025-11-20 15:56:47 -07:00
rmcrackan
22159b79d8 Merge pull request #1429 from Mbucari/master
Minor Bugfixes, new Workflows, and signed macOS bundles, refactor LibationFiles, improve libationCLI
2025-11-20 13:45:25 -05:00
MBucari
7fe170acdf Fix CLI help tex being squashed to a single column 2025-11-20 10:42:14 -07:00
MBucari
c0898a288b Additional LibationCli documentation
Move CLI examples to the bottom of the page.
2025-11-20 09:55:35 -07:00
MBucari
ce2b81036f Add license and settings overrides to LibationCli
- Add `LIBATION_FILES_DIR` environment variable to specify LibationFiles directory instead of appsettings.json
- OptionsBase supports overriding setting
  - Added `EphemeralSettings` which are loaded from Settings.json once and can be modified with the `--override` command parameter
- Added `get-setting` command
  - Prints (editable) settings and their values. Prints specified settings, or all settings if none specified
  - `--listEnumValues` option will list all names for a speficied enum-type settings. If no setting names are specified, prints all enum values for all enum settings.
  - Prints in a text-based table or bare with `-b` switch
- Added `get-license` command which requests a content license and prints it as a json to stdout
- Improved `liberate` command
  - Added `-force` option to force liberation without validation.
  - Added support to download with a license file supplied to stdin
  - Improve startup performance when downloading explicit ASIN(s)
  - Fix long-standing bug where cover art was not being downloading
2025-11-19 23:47:41 -07:00
Michael Bucari-Tovo
65d24ce223 Fix NRE in FileSystemTest when Books dir is invalid (#1435) 2025-11-17 10:54:25 -07:00
MBucari
e8c911e603 Improve management and validation of Libation settings
- Move all settings file logic into new LibationFiles class
  - Configuration.LibationFiles is a singleton instance of the LibationFiles class.
  - A LibationFiles instance is bound to a single appsettings.json path. All updates to LibationFiles location are updated in that appsettings.json file
- Unify initial setup and settings validation process
  - Add LibationSetup which handles all startup validation of settings files and prompts users for setup if needed
  - Added a new LibationUiBase.Tests test project with tests for various
2025-11-17 10:49:23 -07:00
Michael Bucari-Tovo
59f66ff480 Fix subdirectory create failing on root drive (#1432)
This appears to be a bug in .NET. Someone started to fix it in a PR, but it went stale and was auto-closed.

https://github.com/dotnet/runtime/pull/117519
2025-11-14 13:44:46 -07:00
Michael Bucari-Tovo
e05dcd6f54 Don't overwrite LibationFiles setting in appsettings.json 2025-11-14 13:35:36 -07:00
Michael Bucari-Tovo
d1ce9d5a83 Update Mac Workflow
- Add new repo variables
  - `SIGN_MAC_APP_ON_VALIDATE` will force sign/notarize on the validate workflow (normally only done for releases)
  - `WAIT_FOR_NOTARIZE` Causes the build-mac workflow to wait for apple to notarize the bundle so that it can be stapled. This is usually fast (1-2 mis), but can be very long and may cause workflow runners to time out.
2025-11-14 11:19:21 -07:00
Mbucari
2213f5c86a Change msaOS updater to get DMGs
This will break _Automatic_ updates for existing mac users (although I'm not sure it worked all that well to begin with. However, the update notification dialog has had a link to download the bundle manually for a long time now. Old users will still be notified of the new release and be given a direct link to download it.
2025-11-13 23:15:29 -07:00
Mbucari
d6b232f342 Update Logging and Error Handling
- Add Configuration.LoggingEnabled property which gets set as soon as Serilog is configured
- Add error handling to InteropFactory
2025-11-13 23:12:57 -07:00
Mbucari
f29c19beb8 Update .gitignore 2025-11-13 23:04:33 -07:00
Mbucari
9f6d08fc1f Update Workflows
- Simplify workflows build commands
- Don't build ReadyToRun on validate
- Move get-version into it's own job in build.yml
- Split  macOS into it's own reusable workflow
  - Add app bundle code signing
  - Add notarization
2025-11-13 22:59:26 -07:00
MBucari
717dfcd923 Fix trying to cancel disposed cancellation token source 2025-11-11 14:06:54 -07:00
MBucari
a3d181b2ec Fix Primary Screen NRE (#1420) 2025-11-11 03:21:20 -07:00
Mbucari
d16eeea56b Improve detection of NativeWebDialog crash 2025-11-10 22:58:05 -07:00
Mbucari
5d2513ec33 Improve button display size uniformity. 2025-11-10 22:21:51 -07:00
Mbucari
e5043dcf40 Move button styling to App.xaml 2025-11-10 21:42:34 -07:00
Mbucari
c61bfb4134 Fix EditQuickFilters not displaying with no filters 2025-11-10 21:42:06 -07:00
Robert
d47a2595b9 incr ver 2025-11-10 22:19:24 -05:00
rmcrackan
55e74db4fb Merge pull request #1419 from Mbucari/master
Bug Fixes and UI Improvement
2025-11-10 22:17:12 -05:00
MBucari
0a171222bc Update Dependency 2025-11-10 19:29:30 -07:00
MBucari
c2093157ca Add dolby atmos logo for spatial audiobooks 2025-11-10 19:28:18 -07:00
MBucari
8e073800cd Fix BookDetailsDialog crash when changing error status 2025-11-10 18:25:59 -07:00
MBucari
1daf07b882 Improve logging 2025-11-10 17:58:48 -07:00
MBucari
27a23a16d6 Update AAXClean 2025-11-10 17:34:17 -07:00
Michael Bucari-Tovo
c878b9fec0 Detect webview crash and disable webview login 2025-11-10 13:14:23 -07:00
rmcrackan
7a01f075ac Merge pull request #1415 from Mbucari/master
Fix minor UI bugs
2025-11-08 18:04:13 -05:00
Michael Bucari-Tovo
23d391485d Update AboutDialog and add recent contributors 2025-11-07 10:35:33 -07:00
Michael Bucari-Tovo
46be532740 Improve SearchSyntaxDialog
- Double-clicking a tag will paste the tage into the search bar
- SearchSyntaxDialog now modeless
2025-11-06 23:53:57 -07:00
Michael Bucari-Tovo
e2fd88d075 Improve ScanAccountsDialog usability 2025-11-06 23:24:17 -07:00
Michael Bucari-Tovo
bb0dea3fa9 Improve EditReplacementChars dialog usability 2025-11-06 22:49:09 -07:00
Michael Bucari-Tovo
def0b1f611 Prevent crash if watched RootDirectory is deleted 2025-11-06 14:57:54 -07:00
Michael Bucari-Tovo
bfee579719 Fix DirectoryOrCustomSelectControl 2025-11-06 13:47:51 -07:00
Mbucari
d4139861f3 Only allow mocking lobby bugging 2025-11-06 07:59:55 -07:00
225 changed files with 4129 additions and 3023 deletions

View File

@@ -6,63 +6,46 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
runs_on:
publish-r2r:
type: boolean
retention-days:
type: number
architecture:
type: string
description: "The GitHub hosted runner to use"
description: "CPU architecture targeted by the build."
required: true
OS:
type: string
description: >
The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
RELEASE_NAME: "chardonnay"
jobs:
build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ${{ inputs.runs_on }}
runs-on: ubuntu-latest
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
inputVersion="${{ inputs.version_override }}"
if [[ "${#inputVersion}" -gt 0 ]]
then
version="${inputVersion}"
else
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
@@ -70,63 +53,31 @@ jobs:
id: publish
working-directory: ./Source
run: |
if [[ "${{ inputs.OS }}" == "MacOS" ]]
then
display_os="macOS"
RUNTIME_ID="osx-${{ inputs.architecture }}"
else
display_os="Linux"
RUNTIME_ID="linux-${{ inputs.architecture }}"
fi
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
echo "Runtime Identifier: $RUNTIME_ID"
echo "Output Directory: $OUTPUT"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
run: |
BUNDLE_DIR=$(pwd)
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

104
.github/workflows/build-mac.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
# build-mac.yml
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
sign-app:
type: boolean
description: "Wheather to sign an notorize the app bundle and dmg."
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
jobs:
build:
name: "macOS-${{ inputs.architecture }}"
runs-on: macos-latest
env:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v5
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_MacOS.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Notarize bundle
if: ${{ inputs.sign-app }}
run: |
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
WAIT="--wait"
fi
echo "::debug::Submitting the disk image for notarization"
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
echo "$RESPONSE"
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
echo "::debug::Stapling the notarization ticket to the disk image"
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,113 +6,77 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
jobs:
build:
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
name: "Windows-${{ matrix.release_name }}-x64"
runs-on: windows-latest
env:
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
RUNTIME_ID: "win-${{ inputs.architecture }}"
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
- ui: WinForms
release_name: classic
prefix: Classic-
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v5
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LibationCli/LibationCli.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
$PUBLISH_ARGS=@(
"--runtime", "win-x64",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
working-directory: ./bin
run: |
$bin_dir = "${{ env.OUTPUT_NAME }}\"
$delfiles = @(
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json"
)
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-x64.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
Compress-Archive -Path * -DestinationPath "$artifact"
- name: Publish artifact
uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
name: ${{ steps.zip.outputs.artifact }}
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,26 +6,51 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
description: "Libation version number"
required: true
dotnet-version:
type: string
default: "10.x"
description: ".NET version to target"
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
description: "Whether to run unit tests prior to publishing."
publish-r2r:
type: boolean
description: "Whether to publish assemblies as ReadyToRun."
release:
type: boolean
description: "Whether this workflow is being called as a release"
retention-days:
type: number
description: "Number of days the artifacts are to be retained."
jobs:
jobs:
windows:
strategy:
matrix:
architecture: [x64]
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
macOS:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-mac.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
secrets: inherit
linux:
strategy:
@@ -34,20 +59,11 @@ jobs:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: ubuntu-latest
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}
macos:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: macos-latest
OS: MacOS
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -8,7 +8,7 @@ on:
- "v*"
jobs:
prerelease:
runs-on: ubuntu-latest
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
@@ -31,9 +31,11 @@ jobs:
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
libation-version: ${{ needs.prerelease.outputs.version }}
publish-r2r: true
release: true
release:
needs: [prerelease, build]

View File

@@ -17,6 +17,6 @@ jobs:
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml

View File

@@ -15,7 +15,7 @@ jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop

View File

@@ -10,12 +10,31 @@ on:
branches: [master]
jobs:
get_version:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get version
id: get_version
run: |
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
build:
needs: [get_version]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml
with:
version: ${GITHUB_SHA}
version: ${{ needs.get_version.outputs.version }}
release: false
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}

8
.gitignore vendored
View File

@@ -370,4 +370,10 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia
*/bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/

View File

@@ -3,8 +3,8 @@
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
}

View File

@@ -1,16 +1,19 @@
# Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
COPY Source /Source
RUN dotnet publish \
/Source/LibationCli/LibationCli.csproj \
--os linux \
--arch ${TARGETARCH} \
--configuration Release \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
-p:PublishProtocol=FileSystem \
-p:PublishReadyToRun=true \
-p:SelfContained=true
FROM mcr.microsoft.com/dotnet/runtime:9.0
FROM mcr.microsoft.com/dotnet/runtime:10.0
ARG USER_UID=1001
ARG USER_GID=1001

View File

@@ -10,12 +10,10 @@
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
- [Command Line Interface](#command-line-interface)
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
@@ -39,59 +37,6 @@ In addition to the options that are enabled if you allow Libation to "fix up" th
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
```
help
libationcli --help
verb-specific help
libationcli scan --help
scan all libraries
libationcli scan
scan only libraries for specific accounts
libationcli scan nickname1 nickname2
convert all m4b files to mp3
libationcli convert
liberate all books and pdfs
libationcli liberate
liberate pdfs only
libationcli liberate --pdf
libationcli liberate -p
Copy the local sqlite database to postgres
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
export library to file
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
@@ -113,4 +58,102 @@ The below video demonstrates using the theme editor to make changes to the Dark
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
#### Help
```console
libationcli --help
```
#### Verb-Specific Help
```console
libationcli scan --help
```
#### Scan All Libraries
```console
libationcli scan
```
#### Scan Only Libraries for Specific Accounts
```console
libationcli scan nickname1 nickname2
```
#### Convert All m4b Files to mp3
```console
libationcli convert
```
#### Liberate All Books and Pdfs
```console
libationcli liberate
```
#### Liberate Pdfs Only
```console
libationcli liberate --pdf
libationcli liberate -p
```
#### Force Book(s) to Re-Liberate
```console
libationcli liberate --force
libationcli liberate -f
```
#### List Libation Settings
```console
libationcli get-setting
libationcli get-setting -b
libationcli get-setting FileDownloadQuality
```
#### Override Libation Settings for the Command
```console
libationcli liberate B017V4IM1G -override FileDownloadQuality=Normal
libationcli liberate B017V4IM1G -o FileDownloadQuality=normal -o UseWidevine=true Request_xHE_AAC=true -f
```
#### Copy the Local SQLite Database to Postgres
```console
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
```
#### Export Library to File
```console
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
#### Set Download Status
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
```console
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
#### Get a Content License Without Downloading
```console
libationcli get-license B017V4IM1G
```
#### Example Powershell Script to Download Four Differenf Versions f the Same Book
```powershell
$asin="B017V4IM1G"
$xHE_AAC=@('true', 'false')
$Qualities=@('Normal', 'High')
foreach($q in $Qualities){
foreach($x in $xHE_AAC){
$license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x
echo $($license | ConvertFrom-Json).ContentMetadata.content_reference
echo $license | ./libationcli liberate --force
}
}
```

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb

View File

@@ -3,6 +3,7 @@
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ]
then
@@ -28,12 +29,9 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" $ARCH
if [ "$SIGN_WITH_KEY" != "true" ]
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
fi
BUNDLE=./Libation.app
@@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
echo "Moving background.png file..."
mv $BUNDLE_MACOS/background.png ./background.png
echo "Moving background.png file..."
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
@@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
echo "Signing executables in: $BUNDLE"
codesign --force --deep -s - $BUNDLE
all_identities=$(security find-identity -v -p codesigning)
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
echo "Creating app bundle: $APP_FILE"
tar -czvf $APP_FILE $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing executables in: $BUNDLE"
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
codesign --verify --verbose "$BUNDLE"
else
echo "Signing with empty key: $BUNDLE"
codesign --force --deep -s - $BUNDLE
fi
mkdir bundle
echo "moving to ./bundle/$APP_FILE"
mv $APP_FILE ./bundle/$APP_FILE
echo "Creating app disk image: $DMG_FILE"
mkdir Libation
mv $BUNDLE ./Libation/$BUNDLE
mv Libation_DS_Store Libation/.DS_Store
mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
# Create a .DS_Store by:
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
# - Open the folder and edit it to your liking.
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
rm -r $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!"

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.0.2.2" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -15,6 +15,7 @@ namespace AaxDecrypter
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));

View File

@@ -306,7 +306,7 @@ namespace AaxDecrypter
if (WritePosition > endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (TaskCanceledException)
catch (OperationCanceledException)
{
Serilog.Log.Information("Download was cancelled");
}
@@ -402,7 +402,7 @@ namespace AaxDecrypter
*/
protected override void Dispose(bool disposing)
{
if (disposing && !disposed)
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{
_cancellationSource.Cancel();
DownloadTask?.GetAwaiter().GetResult();
@@ -413,7 +413,6 @@ namespace AaxDecrypter
OnUpdate(waitForWrite: true);
}
disposed = true;
base.Dispose(disposing);
}

View File

@@ -1,6 +1,6 @@
using FileManager;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase

View File

@@ -1,8 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.7.0.1</Version>
<TargetFramework>net10.0</TargetFramework>
<Version>12.7.2.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -79,8 +79,17 @@ namespace AppScaffolding
}
/// <summary>most migrations go in here</summary>
public static void RunPostConfigMigrations(Configuration config)
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
{
if (ephemeralSettings)
{
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
config.LoadEphemeralSettings(settings);
}
else
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
AudibleApiStorage.EnsureAccountsSettingsFileExists();
//
@@ -150,7 +159,7 @@ namespace AppScaffolding
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -234,6 +243,7 @@ namespace AppScaffolding
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
#nullable enable
private static void logStartupState(Configuration config)
{
#if DEBUG
@@ -247,9 +257,11 @@ namespace AppScaffolding
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
static int fileCount(FileManager.LongPath longPath)
static int fileCount(FileManager.LongPath? longPath)
{
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
catch { return -1; }
}
@@ -289,8 +301,8 @@ namespace AppScaffolding
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
private static void wireUpSystemEvents(Configuration configuration)
#nullable restore
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
@@ -433,8 +445,8 @@ namespace AppScaffolding
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{

View File

@@ -7,6 +7,7 @@ using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace AppScaffolding
{
/// <summary>
@@ -20,21 +21,21 @@ namespace AppScaffolding
/// </summary>
public static class UNSAFE_MigrationHelper
{
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
public static string? SettingsDirectory
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
#region appsettings.json
public static bool APPSETTINGS_TryGet(string key, out string value)
public static bool APPSETTINGS_TryGet(string key, out string? value)
{
bool success = false;
JToken val = null;
JToken? val = null;
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
@@ -59,7 +60,10 @@ 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)
{
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
return;
var startingContents = File.ReadAllText(appSettingsFile);
JObject jObj;
try
@@ -82,40 +86,37 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
public static bool Settings_TryGet(string key, out string value)
public static bool Settings_TryGet(string key, out string? value)
{
bool success = false;
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
@@ -157,10 +158,10 @@ namespace AppScaffolding
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
JArray? array = null;
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
length = array.Count;
length = array?.Count ?? 0;
return true;
}
@@ -171,8 +172,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
});
}
@@ -200,8 +200,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
array.RemoveAt(position);
});
}
@@ -228,7 +227,7 @@ namespace AppScaffolding
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!SettingsJson_Exists)
if (!File.Exists(SettingsJsonPath))
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
@@ -260,7 +259,7 @@ namespace AppScaffolding
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@@ -69,10 +69,10 @@ namespace ApplicationServices
return Count;
}
public void Execute()
public async Task ExecuteAsync()
{
foreach (var a in actionSets)
a.LibraryBooks.UpdateBookStatus(a.newStatus);
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
}
}
}

View File

@@ -3,20 +3,20 @@ using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices
{
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> InstanceQueue<LibationContext>.WaitToCreateInstance(() =>
{
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
context.Database.Migrate();
return context;
});
{
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
context.Database.Migrate();
return context;
}
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
@@ -24,5 +24,17 @@ namespace ApplicationServices
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
{
using var context = GetContext();
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
}
}
}

View File

@@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
@@ -11,8 +6,14 @@ using Dinah.Core.Logging;
using DtoImporterService;
using FileManager;
using LibationFileManager;
using Microsoft.Extensions.DependencyModel;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger;
#nullable enable
@@ -70,7 +71,7 @@ namespace ApplicationServices
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -141,16 +142,16 @@ namespace ApplicationServices
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -180,7 +181,8 @@ namespace ApplicationServices
}
}
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, "item");
ArgumentValidator.EnsureNotNull(accountId, "accountId");
@@ -203,35 +205,23 @@ namespace ApplicationServices
return 0;
}
using var context = DbContexts.GetContext();
return DoDbSizeChangeOperation(ctx =>
{
var bookImporter = new BookImporter(ctx);
bookImporter.Import(importItems);
var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId);
var bookImporter = new BookImporter(context);
await Task.Run(() => bookImporter.Import(importItems));
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId));
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
context.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
try
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
}
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
ctx.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
});
}
private static LogArchiver? openLogArchive(string? archivePath)
{
@@ -268,7 +258,7 @@ namespace ApplicationServices
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
@@ -347,23 +337,21 @@ namespace ApplicationServices
}
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
private static async Task<int> ImportIntoDbAsync(List<ImportItem> importItems) => await Task.Run(() => importIntoDb(importItems));
private static int importIntoDb(List<ImportItem> importItems)
{
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
logTime("importIntoDbAsync -- pre db");
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
int newCount = 0;
return newCount;
}
DoDbSizeChangeOperation(ctx =>
{
var libraryBookImporter = new LibraryBookImporter(ctx);
newCount = libraryBookImporter.Import(importItems);
logTime("importIntoDbAsync -- post Import()");
});
return newCount;
}
public static int SaveContext(LibationContext context)
{
@@ -389,57 +377,58 @@ namespace ApplicationServices
#region remove/restore books
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{
try
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
{
lb.IsDeleted = true;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
return qtyChanges;
}
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error removing books");
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
return qtyChanges;
return DoDbSizeChangeOperation(ctx =>
{
ctx.LibraryBooks.RemoveRange(libraryBooks);
ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
});
}
catch (Exception ex)
{
@@ -448,36 +437,40 @@ namespace ApplicationServices
}
}
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
static int DoDbSizeChangeOperation(Action<LibationContext> action)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
int qtyChanges;
List<LibraryBook>? library;
using var context = DbContexts.GetContext();
using (var context = DbContexts.GetContext())
{
action?.Invoke(context);
context.LibraryBooks.RemoveRange(libraryBooks);
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
library = qtyChanges == 0 ? null : context.GetLibrary_Flat_NoTracking(includeParents: true);
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
if (library is not null)
finalizeLibrarySizeChange(library);
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error performing DB Size change operation");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange(LibationContext context)
private static void finalizeLibrarySizeChange(List<LibraryBook> library)
{
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library);
}
@@ -490,21 +483,21 @@ namespace ApplicationServices
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
public static async Task<int> UpdateUserDefinedItemAsync(
this LibraryBook lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
=> await UpdateUserDefinedItemAsync([lb], tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
public static async Task<int> UpdateUserDefinedItemAsync(
this IEnumerable<LibraryBook> lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating? rating = null)
=> updateUserDefinedItem(
=> await UpdateUserDefinedItemAsync(
lb,
udi => {
// blank tags are expected. null tags are not
@@ -521,52 +514,54 @@ namespace ApplicationServices
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdatePdfStatusAsync(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdatePdfStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdateTags(this LibraryBook libraryBook, string tags)
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.updateUserDefinedItem(action);
public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> await UpdateUserDefinedItemAsync([libraryBook], action);
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> Task.Run(() => libraryBooks.updateUserDefinedItem(action));
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
int qtyChanges;
using (var context = DbContexts.GetContext())
{
action?.Invoke(book.Book.UserDefinedItem);
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
action?.Invoke(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
@@ -582,7 +577,7 @@ namespace ApplicationServices
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
=> book.AudioExists ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
@@ -650,7 +645,7 @@ namespace ApplicationServices
var pdfResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Where(lb => lb.Book.HasPdf)
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList();

View File

@@ -150,12 +150,12 @@ namespace ApplicationServices
Locale = a.Book.Locale,
Title = a.Book.Title,
Subtitle = a.Book.Subtitle,
AuthorNames = a.Book.AuthorNames(),
NarratorNames = a.Book.NarratorNames(),
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf(),
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),

View File

@@ -18,68 +18,64 @@ namespace AudibleUtilities
public string AccountId { get; }
// user-friendly, non-canonical name. mutable
private string _accountName;
public string AccountName
{
get => _accountName;
get => field;
set
{
if (string.IsNullOrWhiteSpace(value))
return;
var v = value.Trim();
if (v == _accountName)
if (v == field)
return;
_accountName = v;
field = v;
update();
}
}
// whether to include this account when scanning libraries.
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
private bool _libraryScan = true;
public bool LibraryScan
{
get => _libraryScan;
get => field;
set
{
if (value == _libraryScan)
if (value == field)
return;
_libraryScan = value;
field = value;
update();
}
}
private string _decryptKey = "";
/// <summary>aka: activation bytes</summary>
public string DecryptKey
{
get => _decryptKey;
get => field ?? "";
set
{
var v = (value ?? "").Trim();
if (v == _decryptKey)
if (v == field)
return;
_decryptKey = v;
field = v;
update();
}
}
private Identity _identity;
public Identity IdentityTokens
{
get => _identity;
get => field;
set
{
if (_identity is null && value is null)
if (field is null && value is null)
return;
if (_identity is not null)
_identity.Updated -= update;
if (field is not null)
field.Updated -= update;
if (value is not null)
value.Updated += update;
_identity = value;
field = value;
update();
}
}

View File

@@ -25,7 +25,7 @@ namespace AudibleUtilities
public static class AudibleApiStorage
{
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.5.0.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.0" />
<PackageReference Include="AudibleApi" Version="10.1.0.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,23 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,23 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
@@ -10,15 +10,15 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.10">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -13,69 +13,74 @@ namespace DataLayer
.Where(a => a.Role == role)
.OrderBy(a => a.Order);
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
extension(Book book)
{
public string SeriesSortable() => Formatters.GetSortName(book.SeriesNames(true));
public string TitleSortable() => Formatters.GetSortName(book.Title + book.Subtitle);
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public static bool Audio_Exists(this Book book) => book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
/// <summary>True if exists and IsLiberated. Else false</summary>
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
public string AuthorNames => string.Join(", ", book.Authors.Select(a => a.Name));
public string NarratorNames => string.Join(", ", book.Narrators.Select(n => n.Name));
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public bool AudioExists => book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated or LiberatedStatus.Error;
/// <summary>True if exists and IsLiberated. Else false</summary>
public bool PdfExists => book.UserDefinedItem.PdfStatus == LiberatedStatus.NotLiberated;
/// <summary> Whether the book has any supplements </summary>
public bool HasPdf => book.Supplements.Any();
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames(true));
public static bool HasPdf(this Book book) => book.Supplements.Any();
public static string SeriesNames(this Book book, bool includeIndex = false)
{
if (book.SeriesLink is null)
return "";
public string SeriesNames(bool includeIndex = false)
{
if (book.SeriesLink is null)
return "";
// first: alphabetical by name
var withNames = book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(getSeriesNameString)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = book.SeriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
// first: alphabetical by name
var withNames = book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(getSeriesNameString)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = book.SeriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
string getSeriesNameString(SeriesBook sb)
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
string getSeriesNameString(SeriesBook sb)
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
}
public string[] LowestCategoryNames()
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public string[] AllCategoryNames()
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public string[] AllCategoryIds()
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
}
public static string[] LowestCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public static string[] AllCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public static string[] AllCategoryIds(this Book book)
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{
@@ -93,7 +98,7 @@ namespace DataLayer
return titlesAgg;
}
public static float FirstScore(this Rating rating)
public static float FirstScore(this Rating rating)
=> rating.OverallRating > 0 ? rating.OverallRating
: rating.PerformanceRating > 0 ? rating.PerformanceRating
: rating.StoryRating;

View File

@@ -25,16 +25,17 @@ namespace DataLayer
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true)
{
var libraryQuery
= context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
.GetLibrary();
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)

View File

@@ -48,9 +48,21 @@ namespace DtoImporterService
.Distinct()
.ToHashSet();
Cache = DbContext.Books
.GetBooks(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
if (productIds.Count > 100)
{
//For large imports, it is faster to get the whole library and filter in memory.
Cache = DbContext.Books
.GetBooks()
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
}
else
{
Cache = DbContext.Books
.GetBooks(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
}
}
private int upsertBooks(IEnumerable<ImportItem> importItems)

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -23,9 +23,7 @@ namespace FileLiberator
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
using var context = ApplicationServices.DbContexts.GetContext();
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
LibraryBook seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
if (seriesParent is not null)
{
return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");

View File

@@ -23,7 +23,12 @@ namespace FileLiberator
private CancellationTokenSource? cancellationTokenSource;
private AudiobookDownloadBase? abDownloader;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
/// <summary>
/// Optional override to supply license info directly instead of querying the api based on Configuration options
/// </summary>
public DownloadOptions.LicenseInfo? LicenseInfo { get; set; }
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.AudioExists;
public override async Task CancelAsync()
{
if (abDownloader is not null) await abDownloader.CancelAsync();
@@ -38,13 +43,15 @@ namespace FileLiberator
try
{
if (libraryBook.Book.Audio_Exists())
if (libraryBook.Book.AudioExists)
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
DownloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
LicenseInfo ??= await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, cancellationToken);
using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration.Instance, LicenseInfo);
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile)
@@ -63,13 +70,16 @@ namespace FileLiberator
//Set the last downloaded information on the book so that it can be used in the naming templates,
//but don't persist it until everything completes successfully (in the finally block)
Serilog.Log.Verbose("Setting last downloaded info for {@Book}", libraryBook.LogFriendly());
var audioFormat = GetFileFormatInfo(downloadOptions, audioFile);
var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version;
libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion);
//Verbose logging inside getDestinationDirectory
var finalStorageDir = getDestinationDirectory(libraryBook);
//post-download tasks done in parallel.
Serilog.Log.Verbose("Starting post-liberation finalization tasks");
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
Task[] finalTasks =
[
@@ -82,19 +92,26 @@ namespace FileLiberator
try
{
Serilog.Log.Verbose("Awaiting post-liberation finalization tasks");
await Task.WhenAll(finalTasks);
}
catch when (!moveFilesTask.IsFaulted)
catch (Exception ex)
{
Serilog.Log.Verbose(ex, "An error occurred in the post-liberation finalization tasks");
//Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory
if (moveFilesTask.IsFaulted)
throw;
}
finally
{
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
Serilog.Log.Verbose("Updating liberated status for {@Book}", libraryBook.LogFriendly());
await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
Serilog.Log.Verbose("Setting directory time for {@Book}", libraryBook.LogFriendly());
SetDirectoryTime(libraryBook, finalStorageDir);
Serilog.Log.Verbose("Deleting cache files for {@Book}", libraryBook.LogFriendly());
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
{
//Delete cache files only after the download/decrypt operation completes successfully.
@@ -103,6 +120,7 @@ namespace FileLiberator
}
}
Serilog.Log.Verbose("Returning successful status handler for {@Book}", libraryBook.LogFriendly());
return new StatusHandler();
}
catch when (cancellationToken.IsCancellationRequested)
@@ -122,20 +140,21 @@ namespace FileLiberator
private async Task<AudiobookDecryptResult> DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken)
{
var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory;
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
//Directories are validated prior to beginning download/decrypt
var outputDir = AudibleFileStorage.DecryptInProgressDirectory!;
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory!;
var result = new AudiobookDecryptResult(false, [], []);
try
{
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions);
abDownloader = new UnencryptedAudiobookDownloader(outputDir, cacheDir, dlOptions);
else
{
AaxcDownloadConvertBase converter
= dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ?
new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions);
new AaxcDownloadMultiConverter(outputDir, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outputDir, cacheDir, dlOptions);
if (dlOptions.Config.AllowLibationFixup)
converter.RetrievedMetadata += Converter_RetrievedMetadata;
@@ -169,7 +188,7 @@ namespace FileLiberator
void AbDownloader_TempFileCreated(object? sender, TempFile e)
{
if (Path.GetDirectoryName(e.FilePath) == outpoutDir)
if (Path.GetDirectoryName(e.FilePath) == outputDir)
{
result.ResultFiles.Add(e);
}
@@ -495,9 +514,15 @@ namespace FileLiberator
#region Macros
private static string getDestinationDirectory(LibraryBook libraryBook)
{
Serilog.Log.Verbose("Getting destination directory for {@Book}", libraryBook.LogFriendly());
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
Serilog.Log.Verbose("Got destination directory for {@Book}. {@Directory}", libraryBook.LogFriendly(), destinationDir);
if (!Directory.Exists(destinationDir))
{
Serilog.Log.Verbose("Creating destination {@Directory}", destinationDir);
Directory.CreateDirectory(destinationDir);
Serilog.Log.Verbose("Created destination {@Directory}", destinationDir);
}
return destinationDir;
}

View File

@@ -6,6 +6,7 @@ using DataLayer;
using Dinah.Core;
using LibationFileManager;
using NAudio.Lame;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.IO;
@@ -20,19 +21,15 @@ namespace FileLiberator;
public partial class DownloadOptions
{
/// <summary>
/// Initiate an audiobook download from the audible api.
/// Requests a download license from the Api using the Configuration settings to choose the appropriate content.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
var license = await ChooseContent(api, libraryBook, config, token);
Serilog.Log.Logger.Debug("Content License {@License}", new
{
license.DrmType,
license.ContentMetadata.ContentReference.Codec,
license.ContentMetadata.ContentReference.Marketplace,
license.ContentMetadata.ContentReference.ContentSizeInBytes,
license.ContentMetadata.ContentReference.Version,
license.ContentMetadata.ContentReference.FileVersion
license.ContentMetadata.ContentReference
});
token.ThrowIfCancellationRequested();
@@ -47,18 +44,41 @@ public partial class DownloadOptions
license.ContentMetadata.ContentReference.Acr,
license.ContentMetadata.ContentReference.FileVersion);
if (metadata is not null && metadata.ContentReference == license.ContentMetadata.ContentReference)
if (metadata is null)
{
Serilog.Log.Logger.Warning("Unable to retrieve metadata for {@FileReference}", new
{
libraryBook.Book.AudibleProductId,
license.DrmType,
license.ContentMetadata.ContentReference.Acr,
license.ContentMetadata.ContentReference.FileVersion
});
}
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
{
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {@DrmType}. {@Metadata}. {@License} ",
license.DrmType,
metadata.ContentReference,
license.ContentMetadata.ContentReference);
}
else
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
return license;
}
private class LicenseInfo
public class LicenseInfo
{
public DrmType DrmType { get; }
public ContentMetadata ContentMetadata { get; }
public KeyData[]? DecryptionKeys { get; }
public DrmType DrmType { get; set; }
public ContentMetadata ContentMetadata { get; set; }
public KeyData[]? DecryptionKeys { get; set; }
[JsonConstructor]
private LicenseInfo()
{
ContentMetadata = null!;
}
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
{
DrmType = license.DrmType;
@@ -72,7 +92,7 @@ public partial class DownloadOptions
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
Serilog.Log.Logger.Debug("Download Settings {@Settings}", new
Serilog.Log.Logger.Information("Download Settings {@Settings}", new
{
config.FileDownloadQuality,
config.UseWidevine,
@@ -89,9 +109,9 @@ public partial class DownloadOptions
if (config.UseWidevine)
{
if (canUseWidevine)
Serilog.Log.Logger.Information("Unable to get a Widevine CDM. Falling back to ADRM.");
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
else
Serilog.Log.Logger.Information("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
Serilog.Log.Logger.Warning("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
}
token.ThrowIfCancellationRequested();
@@ -145,7 +165,10 @@ public partial class DownloadOptions
}
}
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
/// <summary>
/// Builds DownloadOptions from the given LibraryBook, Configuration, and LicenseInfo.
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio

View File

@@ -18,7 +18,7 @@ namespace FileLiberator
public override string Name => "Download Pdf";
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !libraryBook.Book.PDF_Exists();
&& !libraryBook.Book.PdfExists;
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
@@ -35,7 +35,7 @@ namespace FileLiberator
SetFileTime(libraryBook, actualDownloadedFilePath);
SetDirectoryTime(libraryBook, Path.GetDirectoryName(actualDownloadedFilePath));
}
libraryBook.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>

View File

@@ -14,7 +14,7 @@ namespace FileManager
/// </summary>
public class BackgroundFileSystem : IDisposable
{
public LongPath RootDirectory { get; private set; }
public LongPath? RootDirectory { get; private set; }
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
@@ -51,7 +51,8 @@ namespace FileManager
lock (fsCacheLocker)
{
fsCache.Clear();
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
if (Directory.Exists(RootDirectory))
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
}
}
@@ -60,7 +61,14 @@ namespace FileManager
Stop();
lock (fsCacheLocker)
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
{
if (!Directory.Exists(RootDirectory))
{
RootDirectory = null;
return;
}
fsCache.AddRange(SafestEnumerateFiles(RootDirectory));
}
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory)

View File

@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.3.1" />
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Polly" Version="8.6.4" />
</ItemGroup>

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
#nullable enable
namespace FileManager
{
public static class FileSystemTest
@@ -15,8 +16,10 @@ namespace FileManager
/// <summary>
/// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, &lt;, &gt;, |).
/// </summary>
public static bool CanWriteWindowsInvalidChars(LongPath directoryName)
public static bool CanWriteWindowsInvalidChars(LongPath? directoryName)
{
if (directoryName is null)
return false;
var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString());
return CanWriteFile(testFile);
}
@@ -24,8 +27,10 @@ namespace FileManager
/// <summary>
/// Test if the directory supports filenames with 255 unicode characters.
/// </summary>
public static bool CanWrite255UnicodeChars(LongPath directoryName)
public static bool CanWrite255UnicodeChars(LongPath? directoryName)
{
if (directoryName is null)
return false;
const char unicodeChar = 'ü';
var testFileName = new string(unicodeChar, 255);
var testFile = Path.Combine(directoryName, testFileName);

View File

@@ -263,5 +263,27 @@ namespace FileManager
return foundFiles;
}
/// <summary>
/// Creates a subdirectory or subdirectories on the specified path.
/// The specified path can be relative to this instance of the <see cref="DirectoryInfo"/> class.
/// <para/>
/// Fixes an issue with <see cref="DirectoryInfo.CreateSubdirectory(string)"/> where it fails when the parent <see cref="DirectoryInfo"/> is a drive root.
/// </summary>
/// <param name="path">The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name.</param>
/// <returns>The last directory specified in <paramref name="path"/></returns>
public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path)
{
if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path))
return parent.CreateSubdirectory(path);
// parent is a drive root and subDirectory is relative
//Solves a problem with DirectoryInfo.CreateSubdirectory where it fails
//If the parent DirectoryInfo is a drive root.
var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path));
var directoryInfo = new DirectoryInfo(fullPath);
directoryInfo.Create();
return directoryInfo;
}
}
}

View File

@@ -5,7 +5,7 @@ using System.Linq;
#nullable enable
namespace FileManager;
public interface IPersistentDictionary
public interface IJsonBackedDictionary
{
bool Exists(string propertyName);
string? GetString(string propertyName, string? defaultValue = null);

View File

@@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq;
#nullable enable
namespace FileManager
{
public class PersistentDictionary : IPersistentDictionary
public class PersistentDictionary : IJsonBackedDictionary
{
public string Filepath { get; }
public bool IsReadOnly { get; }
@@ -59,7 +59,7 @@ namespace FileManager
objectCache[propertyName] = defaultValue;
return defaultValue;
}
return IPersistentDictionary.UpCast<T>(obj);
return IJsonBackedDictionary.UpCast<T>(obj);
}
public object? GetObject(string propertyName)

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode>
@@ -70,11 +70,11 @@
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.8" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.8" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.9" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

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

View File

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

View File

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

View File

@@ -80,7 +80,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
@@ -89,7 +89,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
@@ -97,7 +97,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>

View File

@@ -39,19 +39,22 @@ namespace HangoverWinForms
deletedCbl.SetItemChecked(i, false);
}
private void saveBtn_Click(object sender, EventArgs e)
private async void saveBtn_Click(object sender, EventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
saveBtn.Enabled = false;
var qtyChanges = await libraryBooksToRestore.RestoreBooksAsync();
if (qtyChanges > 0)
reload();
}
Invoke(reload);
Invoke(() => saveBtn.Enabled = true);
}
private void reload()
{
deletedCbl.Items.Clear();
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
List<LibraryBook> deletedBooks = DbContexts.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);
setLabel();

View File

@@ -2,7 +2,7 @@
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net9.0-windows7.0</TargetFramework>
<TargetFramework>net10.0-windows7.0</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting>
<AssemblyName>Hangover</AssemblyName>
<UseWindowsForms>true</UseWindowsForms>

View File

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

View File

@@ -1,287 +0,0 @@

Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 17
VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject
add-migrations.ps1 = add-migrations.ps1
REFERENCE.txt = REFERENCE.txt
Upgrading dotnet version.txt = Upgrading dotnet version.txt
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
_AvaloniaUI Primer.txt = _AvaloniaUI Primer.txt
_DB_NOTES.txt = _DB_NOTES.txt
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "6 Application", "6 Application", "{8679CAC8-9164-4007-BDD2-F004810EDA14}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "1 Core Libraries", "1 Core Libraries", "{43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "4 Domain (db)", "4 Domain (db)", "{751093DD-5DBA-463E-ADBE-E05FAFB6983E}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 Utilities (domain ignorant)", "2 Utilities (domain ignorant)", "{7FBBB086-0807-4998-85BF-6D1A49C8AD05}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager", "LibationFileManager\LibationFileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities", "AudibleUtilities\AudibleUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
ProjectSection(ProjectDependencies) = postProject
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {40C67036-C1A7-4FDF-AA83-8EC902E257F3}
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities.Tests", "_Tests\AudibleUtilities.Tests\AudibleUtilities.Tests.csproj", "{788294BE-0D8E-40D4-9CEE-67896FBB52CE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tests\FileLiberator.Tests\FileLiberator.Tests.csproj", "{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverWinForms", "HangoverWinForms\HangoverWinForms.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationAvalonia", "LibationAvalonia\LibationAvalonia.csproj", "{F612D06F-3134-4B9B-95CD-EB3FC798AE60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverAvalonia", "HangoverAvalonia\HangoverAvalonia.csproj", "{8A7B01D3-9830-44FD-91A1-D8D010996BEB}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverBase", "HangoverBase\HangoverBase.csproj", "{5C7005BA-7D83-4E99-8073-D970943A7D61}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Demos", "_Demos", "{185AC9FF-381E-4AA1-B649-9771F4917214}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LoadByOS", "LoadByOS", "{59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrossPlatformClientExe", "_Demos\LoadByOS\CrossPlatformClientExe\CrossPlatformClientExe.csproj", "{CC275937-DFE4-4383-B1BF-1D5D42B70C98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinuxConfigApp", "_Demos\LoadByOS\LinuxConfigApp\LinuxConfigApp.csproj", "{47325742-5B38-48E7-95FB-CD94E6E07332}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsConfigApp", "_Demos\LoadByOS\WindowsConfigApp\WindowsConfigApp.csproj", "{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LoadByOS", "LoadByOS", "{9B906374-1142-4D69-86FF-B384806CA5FE}"
ProjectSection(SolutionItems) = preProject
LoadByOS\README.txt = LoadByOS\README.txt
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinuxConfigApp", "LoadByOS\LinuxConfigApp\LinuxConfigApp.csproj", "{357DF797-4EC2-4DBD-A4BD-D045277F2666}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MacOSConfigApp", "LoadByOS\MacOSConfigApp\MacOSConfigApp.csproj", "{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsConfigApp", "LoadByOS\WindowsConfigApp\WindowsConfigApp.csproj", "{5F65A509-26E3-4B02-B403-EEB6F0EF391F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationUiBase", "LibationUiBase\LibationUiBase.csproj", "{E90C4651-AF11-41B4-A839-10082D0391F9}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Hangover", "Hangover", "{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation UI", "Libation UI", "{53758A35-1C7E-4702-9B96-433ABA457B37}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Libation CLI", "Libation CLI", "{47E27674-595D-4F7A-8CFB-127E768E1D1E}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AssertionHelper", "_Tests\AssertionHelper\AssertionHelper.csproj", "{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Postgres", "DataLayer.Postgres\DataLayer.Postgres.csproj", "{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Sqlite", "DataLayer.Sqlite\DataLayer.Sqlite.csproj", "{1E689E85-279E-39D4-7D97-3E993FB6D95B}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
Release|Any CPU = Release|Any CPU
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}.Release|Any CPU.Build.0 = Release|Any CPU
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}.Release|Any CPU.Build.0 = Release|Any CPU
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}.Release|Any CPU.Build.0 = Release|Any CPU
{393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Debug|Any CPU.Build.0 = Debug|Any CPU
{393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Release|Any CPU.ActiveCfg = Release|Any CPU
{393B5B27-D15C-4F77-9457-FA14BA8F3C73}.Release|Any CPU.Build.0 = Release|Any CPU
{06882742-27A6-4347-97D9-56162CEC9C11}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{06882742-27A6-4347-97D9-56162CEC9C11}.Debug|Any CPU.Build.0 = Debug|Any CPU
{06882742-27A6-4347-97D9-56162CEC9C11}.Release|Any CPU.ActiveCfg = Release|Any CPU
{06882742-27A6-4347-97D9-56162CEC9C11}.Release|Any CPU.Build.0 = Release|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.Build.0 = Release|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.Build.0 = Release|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.Build.0 = Release|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.Build.0 = Release|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.Build.0 = Release|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.Build.0 = Release|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F612D06F-3134-4B9B-95CD-EB3FC798AE60}.Release|Any CPU.Build.0 = Release|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.Build.0 = Release|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.Build.0 = Release|Any CPU
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Release|Any CPU.Build.0 = Release|Any CPU
{47325742-5B38-48E7-95FB-CD94E6E07332}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{47325742-5B38-48E7-95FB-CD94E6E07332}.Debug|Any CPU.Build.0 = Debug|Any CPU
{47325742-5B38-48E7-95FB-CD94E6E07332}.Release|Any CPU.ActiveCfg = Release|Any CPU
{47325742-5B38-48E7-95FB-CD94E6E07332}.Release|Any CPU.Build.0 = Release|Any CPU
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Release|Any CPU.Build.0 = Release|Any CPU
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Debug|Any CPU.Build.0 = Debug|Any CPU
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Release|Any CPU.ActiveCfg = Release|Any CPU
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Release|Any CPU.Build.0 = Release|Any CPU
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Release|Any CPU.Build.0 = Release|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.Build.0 = Release|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E90C4651-AF11-41B4-A839-10082D0391F9}.Release|Any CPU.Build.0 = Release|Any CPU
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4}.Release|Any CPU.Build.0 = Release|Any CPU
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2}.Release|Any CPU.Build.0 = Release|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{428163C3-D558-4914-B570-A92069521877} = {47E27674-595D-4F7A-8CFB-127E768E1D1E}
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{788294BE-0D8E-40D4-9CEE-67896FBB52CE} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{F612D06F-3134-4B9B-95CD-EB3FC798AE60} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{8A7B01D3-9830-44FD-91A1-D8D010996BEB} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{5C7005BA-7D83-4E99-8073-D970943A7D61} = {FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE}
{59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F} = {185AC9FF-381E-4AA1-B649-9771F4917214}
{CC275937-DFE4-4383-B1BF-1D5D42B70C98} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
{47325742-5B38-48E7-95FB-CD94E6E07332} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
{9B906374-1142-4D69-86FF-B384806CA5FE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{357DF797-4EC2-4DBD-A4BD-D045277F2666} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{5F65A509-26E3-4B02-B403-EEB6F0EF391F} = {9B906374-1142-4D69-86FF-B384806CA5FE}
{E90C4651-AF11-41B4-A839-10082D0391F9} = {53758A35-1C7E-4702-9B96-433ABA457B37}
{FDDABAFE-35AD-42FC-AC95-0B1FE0DF0DDE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{53758A35-1C7E-4702-9B96-433ABA457B37} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{47E27674-595D-4F7A-8CFB-127E768E1D1E} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{CFE7A0E5-37FE-40BE-A70B-41B5104181C4} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{1E689E85-279E-39D4-7D97-3E993FB6D95B} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
EndGlobalSection
EndGlobal

72
Source/Libation.slnx Normal file
View File

@@ -0,0 +1,72 @@
<Solution>
<Folder Name="/1 Core Libraries/">
<Project Path="FileManager/FileManager.csproj" />
</Folder>
<Folder Name="/2 Utilities (domain ignorant)/">
<Project Path="AaxDecrypter/AaxDecrypter.csproj" />
</Folder>
<Folder Name="/3 Domain Internal Utilities (db ignorant)/">
<Project Path="AudibleUtilities/AudibleUtilities.csproj" />
<Project Path="LibationFileManager/LibationFileManager.csproj" />
</Folder>
<Folder Name="/4 Domain (db)/">
<Project Path="DataLayer.Postgres/DataLayer.Postgres.csproj" />
<Project Path="DataLayer.Sqlite/DataLayer.Sqlite.csproj" />
<Project Path="DataLayer/DataLayer.csproj" />
</Folder>
<Folder Name="/5 Domain Utilities (db aware)/">
<Project Path="ApplicationServices/ApplicationServices.csproj" />
<Project Path="DtoImporterService/DtoImporterService.csproj" />
<Project Path="FileLiberator/FileLiberator.csproj" />
<Project Path="LibationSearchEngine/LibationSearchEngine.csproj" />
</Folder>
<Folder Name="/6 Application/">
<Project Path="AppScaffolding/AppScaffolding.csproj" />
</Folder>
<Folder Name="/6 Application/Hangover/">
<Project Path="HangoverAvalonia/HangoverAvalonia.csproj" />
<Project Path="HangoverBase/HangoverBase.csproj" />
<Project Path="HangoverWinForms/HangoverWinForms.csproj" />
</Folder>
<Folder Name="/6 Application/Libation CLI/">
<Project Path="LibationCli/LibationCli.csproj" />
</Folder>
<Folder Name="/6 Application/Libation UI/">
<Project Path="LibationAvalonia/LibationAvalonia.csproj" />
<Project Path="LibationUiBase/LibationUiBase.csproj" />
<Project Path="LibationWinForms/LibationWinForms.csproj">
<BuildDependency Project="HangoverWinForms/HangoverWinForms.csproj" />
<BuildDependency Project="LibationCli/LibationCli.csproj" />
</Project>
</Folder>
<Folder Name="/6 Application/LoadByOS/">
<File Path="LoadByOS/README.txt" />
<Project Path="LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj" />
<Project Path="LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj" />
<Project Path="LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" />
</Folder>
<Folder Name="/_Demos/" />
<Folder Name="/_Demos/LoadByOS/">
<Project Path="_Demos/LoadByOS/CrossPlatformClientExe/CrossPlatformClientExe.csproj" />
<Project Path="_Demos/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj" />
<Project Path="_Demos/LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" />
</Folder>
<Folder Name="/_Solution Items/">
<File Path="add-migrations.ps1" />
<File Path="REFERENCE.txt" />
<File Path="Upgrading dotnet version.txt" />
<File Path="_ARCHITECTURE NOTES.txt" />
<File Path="_AvaloniaUI Primer.txt" />
<File Path="_DB_NOTES.txt" />
<File Path="__README - COLLABORATORS.txt" />
</Folder>
<Folder Name="/_Tests/">
<Project Path="_Tests/AssertionHelper/AssertionHelper.csproj" />
<Project Path="_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj" />
<Project Path="_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj" />
<Project Path="_Tests/FileManager.Tests/FileManager.Tests.csproj" />
<Project Path="_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj" />
<Project Path="_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj" />
<Project Path="_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj" />
</Folder>
</Solution>

View File

@@ -84,6 +84,9 @@
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="Button.SaveButton">
<Setter Property="Padding" Value="30,6" />
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
@@ -97,7 +100,11 @@
<ContentPresenter Background="{DynamicResource SystemRegionColor}" Content="{TemplateBinding Content}" />
</ControlTemplate>
</Setter>
</Style>
<Style Selector="^ Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Style>
</Application.Styles>
<NativeMenu.Menu>

View File

@@ -1,4 +1,5 @@
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
@@ -12,6 +13,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.Themes;
using LibationAvalonia.Views;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
@@ -41,49 +43,70 @@ public class App : Application
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
// Chardonnay uses the OnLastWindowClose shutdown mode. As long as the application lifetime
// has one active window, the application will stay alive. Setup windows must be daisy chained,
// each closing windows opens the next window before closing itself to prevent the app from exiting.
// Chardonnay uses the OnExplicitShutdown shutdown mode. The application will stay alive until
// Shutdown() is called on App.Current.ApplicationLifetime.
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
Configuration config = Configuration.Instance;
if (!config.LibationSettingsAreValid)
{
string defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location
string defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
Configuration.SetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
{
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
ShowMainWindow(desktop);
}
else
{
SetupDialog setupDialog = new() { Config = config };
setupDialog.Closing += (_, e) => SetupClosing(setupDialog, desktop, e);
desktop.MainWindow = setupDialog;
}
}
else
{
ShowMainWindow(desktop);
}
RunSetupIfNeededAsync(desktop, Configuration.Instance);
}
base.OnFrameworkInitializationCompleted();
}
private static async void RunSetupIfNeededAsync(IClassicDesktopStyleApplicationLifetime desktop, Configuration config)
{
var setup = new LibationSetup(config.LibationFiles)
{
SetupPromptAsync =() => ShowSetupAsync(desktop),
SelectFolderPromptAsync = () => SelectInstallLocation(desktop, config.LibationFiles)
};
if (await setup.RunSetupIfNeededAsync())
{
// setup succeeded or wasn't needed and LibationFiles are valid
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
ShowMainWindow(desktop);
}
else
{
await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
desktop.Shutdown(-1);
}
}
static async Task<ILibationSetup> ShowSetupAsync(IClassicDesktopStyleApplicationLifetime desktop)
{
var tcs = new TaskCompletionSource<ILibationSetup>();
var setupDialog = new SetupDialog();
desktop.MainWindow = setupDialog;
setupDialog.Closed += (_, _) => tcs.SetResult(setupDialog);
setupDialog.Show();
return await tcs.Task;
}
static async Task<ILibationInstallLocation?> SelectInstallLocation(IClassicDesktopStyleApplicationLifetime desktop, LibationFiles libationFiles)
{
var tcs = new TaskCompletionSource<ILibationInstallLocation>();
var libationFilesDialog = new LibationFilesDialog(libationFiles.Location.PathWithoutPrefix);
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Closed += (_, _) => tcs.SetResult(libationFilesDialog);
libationFilesDialog.Show();
return await tcs.Task;
}
private static async Task RunMigrationsAsync(Configuration config)
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
}
private void DisableAvaloniaDataAnnotationValidation()
{
// Get an array of plugins to remove
@@ -97,133 +120,6 @@ public class App : Application
}
}
private async void SetupClosing(SetupDialog setupDialog, IClassicDesktopStyleApplicationLifetime desktop, System.ComponentModel.CancelEventArgs e)
{
try
{
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory);
setupDialog.Config.Books = Configuration.DefaultBooksDirectory;
if (setupDialog.Config.LibationSettingsAreValid)
{
string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
await RunMigrationsAsync(setupDialog.Config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
else if (setupDialog.IsReturningUser)
{
ShowLibationFilesDialog(desktop, setupDialog.Config);
}
else
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
catch (Exception ex)
{
string title = "Fatal error, pre-logging";
string body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
MessageBoxAlertAdminDialog alert = new(body, title, ex);
desktop.MainWindow = alert;
alert.Show();
}
}
private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config)
{
LibationFilesDialog libationFilesDialog = new();
desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Show();
async void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{
libationFilesDialog.Closing -= WindowClosing;
e.Cancel = true;
if (libationFilesDialog.DialogResult == DialogResult.OK)
OnLibationFilesCompleted(desktop, libationFilesDialog, config);
else
await CancelInstallation(libationFilesDialog);
}
libationFilesDialog.Closing += WindowClosing;
}
private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config)
{
Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
// path did not result in valid settings
DialogResult continueResult = await MessageBox.Show(
libationFilesDialog,
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (continueResult == DialogResult.Yes)
{
config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books));
if (config.LibationSettingsAreValid)
{
await RunMigrationsAsync(config);
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
ShowMainWindow(desktop);
}
else
{
await CancelInstallation(libationFilesDialog);
}
}
else
{
await CancelInstallation(libationFilesDialog);
}
}
libationFilesDialog.Close();
}
private static async Task CancelInstallation(Window window)
{
await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Environment.Exit(-1);
}
private async Task RunMigrationsAsync(Configuration config)
{
// most migrations go in here
AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config);
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config);
}
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
{
Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
@@ -233,6 +129,7 @@ public class App : Application
MainWindow mainWindow = new();
desktop.MainWindow = MainWindow = mainWindow;
mainWindow.Loaded += MainWindow_Loaded;
mainWindow.Closed += (_, _) => desktop.Shutdown();
mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.Show();
}

View File

@@ -117,6 +117,52 @@
a 168,305 -35 0 0 104,-136
</StreamGeometry>
<StreamGeometry x:Key="DolbyAtmosLogoVertical">
M261.017,370.954h-13.752l38.363-88.449h11.241l37.967,88.449h-13.984l-8.988-21.733h-41.977
L261.017,370.954z M274.257,338.352h33.109l-16.497-41.484L274.257,338.352z M390.748,293.373h28.364v-10.868h-69.087v10.868h28.353
v77.581h12.37V293.373z M472.258,282.505h-19.229v88.449h11.985v-73.959h0.246l29.236,73.959h7.87l29.354-73.959h0.255v73.959
h12.368v-88.449h-19.229l-26.12,67.955h-0.257L472.258,282.505z M668.11,326.61c0,6.502-1.138,12.46-3.394,17.883
c-2.253,5.425-5.369,10.094-9.316,14.018c-3.966,3.92-8.678,6.966-14.135,9.146c-5.477,2.169-11.411,3.255-17.824,3.255
s-12.337-1.086-17.751-3.255c-5.434-2.181-10.114-5.227-14.08-9.146c-3.968-3.924-7.041-8.593-9.266-14.018
c-2.222-5.423-3.328-11.381-3.328-17.883c0-6.567,1.106-12.567,3.328-17.99c2.225-5.425,5.298-10.051,9.266-13.901
c3.966-3.838,8.646-6.826,14.08-8.971c5.414-2.132,11.338-3.2,17.751-3.2s12.348,1.068,17.824,3.2
c5.457,2.145,10.169,5.133,14.135,8.971c3.947,3.851,7.063,8.477,9.316,13.901C666.973,314.043,668.11,320.043,668.11,326.61
M655.4,326.61c0-4.595-0.765-8.919-2.254-13.003c-1.522-4.073-3.647-7.667-6.424-10.752c-2.776-3.089-6.116-5.52-10.039-7.308
c-3.914-1.774-8.34-2.669-13.242-2.669c-4.828,0-9.21,0.895-13.124,2.669c-3.913,1.788-7.253,4.219-9.976,7.308
c-2.734,3.085-4.851,6.679-6.359,10.752c-1.5,4.084-2.256,8.408-2.256,13.003c0,4.674,0.756,9.071,2.256,13.188
c1.509,4.115,3.647,7.698,6.413,10.752c2.773,3.047,6.104,5.445,9.974,7.185c3.883,1.743,8.244,2.615,13.072,2.615
s9.221-0.872,13.178-2.615c3.967-1.739,7.327-4.138,10.104-7.185c2.776-3.054,4.901-6.637,6.424-10.752
C654.636,335.682,655.4,331.284,655.4,326.61 M751.896,292.173c-2.606-2.931-6.063-5.26-10.327-7.003
c-4.276-1.739-8.87-2.612-13.771-2.612c-3.479,0-6.945,0.457-10.403,1.361c-3.436,0.915-6.529,2.361-9.252,4.334
c-2.734,1.984-4.956,4.478-6.659,7.481c-1.701,3.016-2.542,6.611-2.542,10.817c0,3.877,0.629,7.12,1.894,9.726
c1.266,2.611,2.926,4.813,4.989,6.6c2.052,1.771,4.401,3.244,7.008,4.387c2.606,1.144,5.265,2.123,7.955,2.92
c2.691,0.861,5.244,1.706,7.658,2.547c2.415,0.829,4.541,1.84,6.349,3.025c1.831,1.191,3.266,2.638,4.339,4.335
c1.075,1.702,1.607,3.823,1.607,6.349c0,2.542-0.521,4.695-1.554,6.472c-1.021,1.787-2.35,3.266-3.966,4.457
c-1.627,1.185-3.436,2.057-5.402,2.611c-1.989,0.558-3.968,0.829-5.935,0.829c-3.895,0-7.488-0.904-10.817-2.723
c-3.328-1.819-5.977-4.196-7.955-7.126l-9.146,7.71c3.243,4.042,7.338,7.094,12.282,9.152c4.958,2.057,10.073,3.078,15.391,3.078
c3.722,0,7.327-0.51,10.859-1.531c3.531-1.037,6.637-2.595,9.328-4.696c2.688-2.099,4.849-4.747,6.476-7.96
c1.607-3.2,2.425-6.981,2.425-11.337c0-4.196-0.744-7.657-2.255-10.39c-1.498-2.734-3.434-5-5.816-6.829
c-2.372-1.818-5.032-3.275-7.955-4.393c-2.936-1.106-5.819-2.101-8.669-2.966c-2.383-0.799-4.604-1.575-6.711-2.325
c-2.105-0.74-3.925-1.654-5.456-2.723c-1.543-1.068-2.776-2.383-3.69-3.919c-0.903-1.548-1.361-3.462-1.361-5.765
c0-2.371,0.489-4.408,1.478-6.115c0.99-1.701,2.277-3.128,3.862-4.27c1.585-1.145,3.349-1.984,5.284-2.495
c1.937-0.521,3.86-0.775,5.766-0.775c3.563,0,6.764,0.733,9.613,2.201c2.851,1.457,5.105,3.345,6.775,5.631L751.896,292.173z
M0,194.145h28.652c53.454,0,97.049-43.594,97.049-97.068c0-53.481-43.595-97.065-97.049-97.065H0V194.145z M276.172,0.011h-28.641
c-53.476,0-97.061,43.584-97.061,97.065c0,53.475,43.584,97.068,97.061,97.068h28.641V0.011z M405.074,0h-70.108v194.145h70.108
c53.517,0,97.069-43.552,97.069-97.068C502.144,43.552,458.591,0,405.074,0 M405.063,164.711h-19.952h-20.729V29.434h20.729h19.952
c37.268,0,67.641,30.375,67.641,67.643C472.704,134.336,442.331,164.711,405.063,164.711 M584.346,59.797
c-37.106,0-67.27,30.168-67.27,67.265c0,37.102,30.163,67.269,67.27,67.269c37.095,0,67.259-30.167,67.259-67.269
C651.604,89.965,621.44,59.797,584.346,59.797 M584.346,167.376c-22.506,0-40.554-18.305-40.554-40.56
c0-22.51,18.294-40.553,40.554-40.553c22.248,0,40.553,18.294,40.553,40.553C624.898,149.322,606.594,167.376,584.346,167.376
M670.643,194.374h29.428V0.031h-29.428V194.374z M792.759,59.809c-14.295,0-27.546,4.488-38.459,12.124V0.031h-29.491l0.01,194.343
H754.3v-12.161c10.913,7.63,24.164,12.129,38.459,12.129c37.095,0,67.278-30.179,67.278-67.271
C860.037,89.977,829.854,59.809,792.759,59.809 M792.759,167.376c-17.985,0-33.119-11.704-38.459-27.78
c-1.339-4.021-2.095-8.312-2.095-12.768c0-4.483,0.756-8.785,2.095-12.806c5.383-16.171,20.634-27.759,38.459-27.759
c22.259,0,40.562,18.305,40.562,40.564C833.32,149.333,815.018,167.376,792.759,167.376 M967.85,59.84l-38.329,86.201L891.169,59.84
h-32.151l54.41,122.304l-1.084,2.376l-0.385,0.846l-11.782,26.61l-0.075,0.207c-3.53,7.907-12.836,11.486-20.729,7.961l-4.223-1.872
l-8.222,18.469l-3.657,8.188h0.01l0.044,0.021l10.188,4.541c19.133,8.541,41.7-0.143,50.262-19.318
c0.076-0.164,69.684-155.714,76.225-170.333H967.85z
</StreamGeometry>
</ResourceDictionary>
</Styles.Resources>
</Styles>

View File

@@ -21,9 +21,7 @@ namespace LibationAvalonia.Controls
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
public bool IsChecked { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public object Item { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}

View File

@@ -0,0 +1,25 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
namespace LibationAvalonia.Controls;
internal class DataGridTextColumnExt : DataGridTextColumn
{
public static readonly StyledProperty<int> MaxLengthProperty =
AvaloniaProperty.Register<DataGridTextColumnExt, int>(nameof(MaxLength));
public int MaxLength
{
get => GetValue(MaxLengthProperty);
set => SetValue(MaxLengthProperty, value);
}
protected override object PrepareCellForEdit(Control editingElement, RoutedEventArgs editingEventArgs)
{
if (editingElement is TextBox textBox)
{
textBox.MaxLength = MaxLength;
}
return base.PrepareCellForEdit(editingElement, editingEventArgs);
}
}

View File

@@ -6,29 +6,63 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="LibationAvalonia.Controls.DirectoryOrCustomSelectControl">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto" Name="grid">
<controls:DirectorySelectControl
Grid.Column="1"
Grid.Row="0"
IsEnabled="{Binding KnownChecked}"
SelectedDirectory="{Binding SelectedDirectory, Mode=TwoWay}"
SubDirectory="{Binding $parent[1].SubDirectory}"
KnownDirectories="{Binding $parent[1].KnownDirectories}" />
<Grid
RowDefinitions="Auto,Auto,Auto"
ColumnDefinitions="Auto,*,Auto">
<RadioButton
Grid.Column="0"
Grid.Row="0"
IsChecked="{Binding KnownChecked, Mode=TwoWay}"/>
<RadioButton
Grid.RowSpan="2"
Name="rbKnown" />
<RadioButton
Grid.Column="0"
Grid.Row="1"
IsChecked="{Binding CustomChecked, Mode=TwoWay}"/>
<TextBlock
Grid.Column="1"
Grid.ColumnSpan="2"
VerticalAlignment="Center"
Margin="10,0"
IsEnabled="False"
IsVisible="{Binding #cmbKnownDirs.SelectedItem, Converter={x:Static ObjectConverters.IsNull}}"
Text="Select Known Directory:" />
<Grid Grid.Column="1" Grid.Row="1" ColumnDefinitions="*,Auto"
IsEnabled="{Binding CustomChecked}">
<TextBox Grid.Column="0" IsReadOnly="True" Text="{Binding CustomDir, Mode=TwoWay}" />
<Button Grid.Column="1" Content="..." Margin="5,0,0,0" Padding="10,0,10,0" Click="CustomDirBrowseBtn_Click" VerticalAlignment="Stretch" />
</Grid>
</Grid>
<controls:WheelComboBox
Grid.Column="1"
Grid.ColumnSpan="2"
HorizontalAlignment="Stretch"
Margin="0,0,0,3"
IsEnabled="{Binding #rbKnown.IsChecked}"
Name="cmbKnownDirs" />
<TextBox
Grid.Row="1"
Grid.Column="1"
Grid.ColumnSpan="2"
IsReadOnly="True"
Margin="0,0,0,8"
Name="tboxKnownDirPath"
IsEnabled="{Binding #rbKnown.IsChecked}"
Text="{Binding #cmbKnownDirs.SelectedItem.Directory}" />
<RadioButton
Grid.Row="2"
Name="rbCustom" />
<TextBox
Grid.Row="2"
Grid.Column="1"
HorizontalAlignment="Stretch"
Name="tboxCustomDirPath"
Margin="0,0,10,0"
VerticalAlignment="Center"
Text="{Binding $parent[1].Directory, Mode=OneWayToSource}"
IsEnabled="{Binding #rbCustom.IsChecked}"/>
<Button
Grid.Row="2"
Grid.Column="2"
Name="btnBrowse"
IsEnabled="{Binding #rbCustom.IsChecked}">
<TextBlock Text="..." />
</Button>
</Grid>
</UserControl>

View File

@@ -1,142 +1,182 @@
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.IO;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Controls
{
public partial class DirectoryOrCustomSelectControl : UserControl
{
public static readonly StyledProperty<List<Configuration.KnownDirectories>> KnownDirectoriesProperty =
AvaloniaProperty.Register<DirectorySelectControl, List<Configuration.KnownDirectories>>(nameof(KnownDirectories), DirectorySelectControl.DefaultKnownDirectories);
public static readonly StyledProperty<IList<Configuration.KnownDirectories>?> KnownDirectoriesProperty =
AvaloniaProperty.Register<DirectoryOrCustomSelectControl, IList<Configuration.KnownDirectories>?>(nameof(KnownDirectories), DefaultKnownDirectories);
public static readonly StyledProperty<string> SubDirectoryProperty =
AvaloniaProperty.Register<DirectorySelectControl, string>(nameof(SubDirectory));
public static readonly StyledProperty<string?> SubDirectoryProperty =
AvaloniaProperty.Register<DirectoryOrCustomSelectControl, string?>(nameof(SubDirectory));
public static readonly StyledProperty<string> DirectoryProperty =
AvaloniaProperty.Register<DirectorySelectControl, string>(nameof(Directory));
public static readonly StyledProperty<string?> DirectoryProperty =
AvaloniaProperty.Register<DirectoryOrCustomSelectControl, string?>(nameof(Directory));
public List<Configuration.KnownDirectories> KnownDirectories
public IList<Configuration.KnownDirectories>? KnownDirectories
{
get => GetValue(KnownDirectoriesProperty);
set => SetValue(KnownDirectoriesProperty, value);
}
public string Directory
public string? Directory
{
get => GetValue(DirectoryProperty);
set => SetValue(DirectoryProperty, value);
}
public string SubDirectory
public string? SubDirectory
{
get => GetValue(SubDirectoryProperty);
set => SetValue(SubDirectoryProperty, value);
}
private readonly DirectoryState directoryState = new();
public static IList<Configuration.KnownDirectories> DefaultKnownDirectories => [
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyMusic,
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.LibationFiles];
private readonly AvaloniaList<KnownDirectoryItem> _knownDirNames;
public DirectoryOrCustomSelectControl()
{
InitializeComponent();
grid.DataContext = directoryState;
directoryState.PropertyChanged += DirectoryState_PropertyChanged;
PropertyChanged += DirectoryOrCustomSelectControl_PropertyChanged;
_knownDirNames = new(GetKnownDirectories(DefaultKnownDirectories));
cmbKnownDirs.ItemsSource = _knownDirNames;
cmbKnownDirs.SelectionChanged += CmbKnownDirs_SelectionChanged;
btnBrowse.Click += Browse_Click;
}
private void DirectoryState_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
private void CmbKnownDirs_SelectionChanged(object? sender, SelectionChangedEventArgs e)
{
if (e.PropertyName is nameof(DirectoryState.SelectedDirectory) or nameof(DirectoryState.KnownChecked) &&
directoryState.KnownChecked &&
directoryState.SelectedDirectory is Configuration.KnownDirectories kdir &&
kdir is not Configuration.KnownDirectories.None)
if (cmbKnownDirs.SelectedItem is KnownDirectoryItem item && item.Directory is not null)
{
Directory = kdir is Configuration.KnownDirectories.AppDir ? Configuration.AppDir_Absolute : Configuration.GetKnownDirectoryPath(kdir);
}
else if (e.PropertyName is nameof(DirectoryState.CustomDir) or nameof(DirectoryState.CustomChecked) &&
directoryState.CustomChecked &&
directoryState.CustomDir is not null)
{
Directory = directoryState.CustomDir;
Directory = item.Directory;
}
}
private class DirectoryState : ViewModels.ViewModelBase
{
private string _customDir;
private string _subDirectory;
private bool _knownChecked;
private bool _customChecked;
private Configuration.KnownDirectories? _selectedDirectory;
public string CustomDir { get => _customDir; set => this.RaiseAndSetIfChanged(ref _customDir, value); }
public string SubDirectory { get => _subDirectory; set => this.RaiseAndSetIfChanged(ref _subDirectory, value); }
public bool KnownChecked { get => _knownChecked; set => this.RaiseAndSetIfChanged(ref _knownChecked, value); }
public bool CustomChecked { get => _customChecked; set => this.RaiseAndSetIfChanged(ref _customChecked, value); }
private IEnumerable<KnownDirectoryItem> GetKnownDirectories(IEnumerable<Configuration.KnownDirectories> knownDirs)
=> knownDirs.Select(k => new KnownDirectoryItem(k, SubDirectory)).Where(k => k.Directory is not null);
public Configuration.KnownDirectories? SelectedDirectory { get => _selectedDirectory; set => this.RaiseAndSetIfChanged(ref _selectedDirectory, value); }
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property == SubDirectoryProperty)
{
foreach (var item in _knownDirNames)
{
item.SubDirectory = SubDirectory;
}
VerifyAndApplyDirectory(Directory);
}
else if (change.Property == KnownDirectoriesProperty)
{
var knownDirs = KnownDirectories?.Count > 0 ? KnownDirectories : DefaultKnownDirectories;
if (!_knownDirNames.Select(k => k.KnownDirectory).SequenceEqual(knownDirs))
{
_knownDirNames.Clear();
_knownDirNames.AddRange(GetKnownDirectories(knownDirs));
}
VerifyAndApplyDirectory(Directory);
}
else if (change.Property == DirectoryProperty)
{
VerifyAndApplyDirectory(Directory);
}
base.OnPropertyChanged(change);
}
private async void CustomDirBrowseBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private void VerifyAndApplyDirectory(string? directory)
{
var options = new Avalonia.Platform.Storage.FolderPickerOpenOptions
if (string.IsNullOrWhiteSpace(Directory))
return;
bool dirIsKnown = false;
foreach (var item in _knownDirNames)
{
if (item.IsSamePathAs(directory))
{
rbKnown.IsChecked = true;
Directory = item.Directory;
cmbKnownDirs.SelectedItem = item;
dirIsKnown = true;
break;
}
}
if (!dirIsKnown)
{
tboxCustomDirPath.Text = directory;
rbCustom.IsChecked = true;
}
}
public async void Browse_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (VisualRoot is not Window window)
return;
var options = new FolderPickerOpenOptions
{
AllowMultiple = false
};
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
directoryState.CustomDir = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? directoryState.CustomDir;
var selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options);
Directory = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? Directory;
}
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
private class KnownDirectoryItem : ReactiveObject
{
if (e.Property == DirectoryProperty)
public Configuration.KnownDirectories KnownDirectory { get; set; }
public string? Directory { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? Name { get; }
public string? SubDirectory
{
var directory = Directory?.Trim() ?? "";
var noSubDir = RemoveSubDirectoryFromPath(directory);
var known = Configuration.GetKnownDirectory(noSubDir);
if (known == Configuration.KnownDirectories.None && noSubDir == Configuration.AppDir_Absolute)
known = Configuration.KnownDirectories.AppDir;
if (known is Configuration.KnownDirectories.None)
get => field;
set
{
directoryState.CustomDir = directory;
directoryState.CustomChecked = true;
}
else
{
directoryState.SelectedDirectory = known;
directoryState.KnownChecked = true;
field = value;
if (Configuration.GetKnownDirectoryPath(KnownDirectory) is string dir)
{
Directory = Path.Combine(dir, field ?? "");
}
}
}
else if (e.Property == KnownDirectoriesProperty &&
KnownDirectories.Count > 0 &&
directoryState.SelectedDirectory is null or Configuration.KnownDirectories.None)
directoryState.SelectedDirectory = KnownDirectories[0];
}
private string RemoveSubDirectoryFromPath(string path)
{
if (string.IsNullOrWhiteSpace(SubDirectory))
return path;
public KnownDirectoryItem(Configuration.KnownDirectories known, string? subDir)
{
Name = known.GetDescription();
KnownDirectory = known;
SubDirectory = subDir;
}
path = path?.Trim() ?? "";
if (string.IsNullOrWhiteSpace(path))
return path;
public bool IsSamePathAs(string? otherPath)
{
if (string.IsNullOrWhiteSpace(otherPath) || string.IsNullOrWhiteSpace(Directory))
return false;
var bottomDir = System.IO.Path.GetFileName(path);
if (SubDirectory.EqualsInsensitive(bottomDir))
return System.IO.Path.GetDirectoryName(path);
try
{
var p1 = Path.GetFullPath(Directory);
var p2 = Path.GetFullPath(otherPath);
return p1.Equals(p2, System.StringComparison.OrdinalIgnoreCase);
}
catch { return false; }
}
return path;
public override string? ToString() => Name?.ToString();
}
}
}

View File

@@ -412,7 +412,7 @@
<Button
Grid.Column="1"
Content="Edit"
Padding="30,0"
Classes="SaveButton"
Margin="10,0,0,0"
VerticalAlignment="Stretch"
Click="EditChapterTitleTemplateButton_Click" />

View File

@@ -8,7 +8,7 @@
x:DataType="vm:ImportantSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Important">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="0"
Margin="5"
@@ -69,9 +69,16 @@
</StackPanel>
</controls:GroupBox>
<CheckBox
Grid.Row="1"
Margin="10,5"
IsChecked="{CompiledBinding UseWebView, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding UseWebViewText}" />
</CheckBox>
<StackPanel
Grid.Row="1" Margin="5"
Grid.Row="2" Margin="5"
Orientation="Horizontal">
<TextBlock
@@ -96,7 +103,7 @@
</StackPanel>
<controls:GroupBox
Grid.Row="2"
Grid.Row="3"
Margin="5"
Label="Display Settings">
<Grid
@@ -151,7 +158,7 @@
</controls:GroupBox>
<Grid
Grid.Row="3"
Grid.Row="4"
ColumnDefinitions="Auto,Auto,*"
Margin="10"
VerticalAlignment="Bottom">

View File

@@ -9,15 +9,15 @@
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="About Libation">
<Grid Margin="10" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto,*">
<Grid Margin="10" RowDefinitions="Auto,Auto,Auto,Auto,*">
<controls:LinkLabel Grid.ColumnSpan="2" FontSize="16" FontWeight="Bold" Text="{Binding Version}" ToolTip.Tip="View Release Notes" Tapped="ViewReleaseNotes_Tapped" />
<controls:LinkLabel FontSize="16" FontWeight="Bold" Text="{Binding Version}" ToolTip.Tip="View Release Notes" Tapped="ViewReleaseNotes_Tapped" />
<controls:LinkLabel Grid.Column="1" FontSize="14" VerticalAlignment="Center" HorizontalAlignment="Right" Text="https://getlibation.com" Tapped="Link_getlibation"/>
<controls:LinkLabel Grid.Row="1" FontSize="14" VerticalAlignment="Center" Text="https://getlibation.com" Tapped="Link_getlibation"/>
<Button Grid.Row="1" Grid.ColumnSpan="2" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Margin="0,20,0,0" IsEnabled="{Binding CanCheckForUpgrade}" Content="{Binding UpgradeButtonText}" Click="CheckForUpgrade_Click" />
<Button Grid.Row="2" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Margin="0,10,0,0" IsEnabled="{Binding CanCheckForUpgrade}" Content="{Binding UpgradeButtonText}" Click="CheckForUpgrade_Click" />
<Canvas Grid.Row="2" Grid.ColumnSpan="2" Margin="0,30,0,20" Width="280" Height="220">
<Canvas Grid.Row="3" Margin="0,30,0,20" Width="280" Height="220">
<Path Stretch="None" Fill="{DynamicResource IconFill}" Data="{DynamicResource LibationCheersIcon}">
<Path.RenderTransform>
<TransformGroup>
@@ -39,7 +39,7 @@
</Path>
</Canvas>
<controls:GroupBox Grid.Row="3" Label="Acknowledgements" Grid.ColumnSpan="2">
<controls:GroupBox Grid.Row="4" Label="Acknowledgements">
<StackPanel>
<StackPanel.Styles>
<Style Selector="controls|LinkLabel">

View File

@@ -64,12 +64,8 @@ namespace LibationAvalonia.Dialogs
public class AboutVM : ViewModelBase
{
public string Version { get; }
public bool CanCheckForUpgrade { get => canCheckForUpgrade; set => this.RaiseAndSetIfChanged(ref canCheckForUpgrade, value); }
public string UpgradeButtonText { get => upgradeButtonText; set => this.RaiseAndSetIfChanged(ref upgradeButtonText, value); }
private bool canCheckForUpgrade = true;
private string upgradeButtonText = "Check for Upgrade";
public bool CanCheckForUpgrade { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = true;
public string UpgradeButtonText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }= "Check for Upgrade";
public IEnumerable<LibationContributor> PrimaryContributors => LibationContributor.PrimaryContributors;
public IEnumerable<LibationContributor> AdditionalContributors => LibationContributor.AdditionalContributors;

View File

@@ -7,14 +7,6 @@
x:Class="LibationAvalonia.Dialogs.AccountsDialog"
Title="Audible Accounts">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="False"
@@ -107,13 +99,12 @@
<Button
Grid.Column="0"
Padding="5,5"
Content="Import from audible-cli"
Click="ImportButton_Clicked" />
<Button
Grid.Column="1"
Padding="30,5"
Classes="SaveButton"
Content="Save"
Click="SaveButton_Clicked" />
</Grid>

View File

@@ -19,15 +19,14 @@ namespace LibationAvalonia.Dialogs
public AvaloniaList<AccountDto> Accounts { get; } = new();
public class AccountDto : ViewModels.ViewModelBase
{
private string _accountId;
public IReadOnlyList<Locale> Locales => AccountsDialog.Locales;
public bool LibraryScan { get; set; } = true;
public string AccountId
{
get => _accountId;
get => field;
set
{
this.RaiseAndSetIfChanged(ref _accountId, value);
this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(IsDefault));
}
}

View File

@@ -7,36 +7,38 @@
Width="650" Height="500"
x:Class="LibationAvalonia.Dialogs.BookDetailsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:BookDetailsDialog+BookDetailsDialogViewModel"
x:CompileBindings="True"
Title="Book Details" Name="BookDetails">
<Grid RowDefinitions="*,Auto,Auto,40">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="*,Auto" Margin="10,10,10,0">
<Panel VerticalAlignment="Top" Margin="5" Background="LightGray" Width="80" Height="80" >
<Image Grid.Column="0" Width="80" Height="80" Source="{Binding Cover}" />
</Panel>
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,*,Auto" Margin="10">
<Image Source="{Binding Cover}" />
<Panel Grid.Column="0" Grid.Row="1">
<Path
Grid.Row="1"
VerticalAlignment="Center"
Stretch="Uniform"
Width="80"
Fill="{DynamicResource IconFill}"
IsVisible="{Binding IsSpatial}"
Data="{StaticResource DolbyAtmosLogoVertical}" />
<controls:LinkLabel
Margin="10"
TextWrapping="Wrap"
TextAlignment="Center"
Tapped="GoToAudible_Tapped"
Text="Open in&#xa;Audible&#xa;(Browser)" />
</Panel>
<controls:LinkLabel
Grid.Row="2"
HorizontalAlignment="Center"
TextAlignment="Center"
VerticalAlignment="Bottom"
TextWrapping="Wrap"
Command="{Binding OpenInAudibleCommand}"
Text="Open in &#xa;Audible&#xa;(Browser)" />
<TextBox
Grid.Column="1"
Grid.Row="0"
Grid.RowSpan="2"
Grid.RowSpan="3"
Margin="10,0,0,0"
TextWrapping="Wrap"
Margin="5"
FontSize="12"
Text="{Binding DetailsText}" />
</Grid>
@@ -91,6 +93,7 @@
MinHeight="25"
Height="25"
VerticalAlignment="Center"
SelectionChanged="BookStatus_SelectionChanged"
SelectedItem="{Binding BookLiberatedSelectedItem, Mode=TwoWay}"
ItemsSource="{Binding BookLiberatedItems}">
@@ -137,7 +140,7 @@
<Button
Grid.Column="1"
Content="Save"
Padding="30,3,30,3"
Classes="SaveButton"
Click="SaveButton_Clicked" />
</Grid>

View File

@@ -1,28 +1,31 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using DataLayer;
using Dinah.Core;
using LibationAvalonia.Controls;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibationAvalonia.Dialogs
{
public partial class BookDetailsDialog : DialogWindow
{
private LibraryBook _libraryBook;
private BookDetailsDialogViewModel _viewModel;
public LibraryBook LibraryBook
{
get => _libraryBook;
get => field;
set
{
_libraryBook = value;
Title = _libraryBook.Book.TitleWithSubtitle;
DataContext = _viewModel = new BookDetailsDialogViewModel(_libraryBook);
field = value;
Title = field.Book.TitleWithSubtitle;
DataContext = _viewModel = new BookDetailsDialogViewModel(field);
}
}
@@ -40,7 +43,7 @@ namespace LibationAvalonia.Dialogs
MainVM.Configure_NonUI();
LibraryBook
= MockLibraryBook
.CreateBook()
.CreateBook(isSpatial: true)
.AddAuthor("Author 2")
.AddNarrator("Narrator 2")
.AddSeries("Series Name", 1)
@@ -55,59 +58,53 @@ namespace LibationAvalonia.Dialogs
LibraryBook = libraryBook;
}
protected override void SaveAndClose()
protected override async Task SaveAndCloseAsync()
{
LibraryBook.UpdateUserDefinedItem(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
base.SaveAndClose();
await LibraryBook.UpdateUserDefinedItemAsync(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
await base.SaveAndCloseAsync();
}
public void GoToAudible_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
public void BookStatus_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
var locale = AudibleApi.Localization.Get(_libraryBook.Book.Locale);
var link = $"https://www.audible.{locale.TopDomain}/pd/{_libraryBook.Book.AudibleProductId}";
Go.To.Url(link);
if (sender is not WheelComboBox { SelectedItem: liberatedComboBoxItem { Status: LiberatedStatus.Error } } &&
_viewModel.BookLiberatedItems.SingleOrDefault(s => s.Status == LiberatedStatus.Error) is liberatedComboBoxItem errorItem)
{
_viewModel.BookLiberatedItems.Remove(errorItem);
}
}
public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> SaveAndClose();
private class BookDetailsDialogViewModel : ViewModelBase
public class liberatedComboBoxItem
{
public class liberatedComboBoxItem
{
public LiberatedStatus Status { get; set; }
public string Text { get; set; }
public override string ToString() => Text;
}
public LiberatedStatus Status { get; set; }
public string Text { get; set; }
public override string ToString() => Text;
}
public class BookDetailsDialogViewModel : ViewModelBase
{
public Bitmap Cover { get; set; }
public string DetailsText { get; set; }
public string Tags { get; set; }
public bool IsSpatial { get; }
public bool HasPDF => PdfLiberatedItems?.Count > 0;
private liberatedComboBoxItem _bookLiberatedSelectedItem;
public ObservableCollection<liberatedComboBoxItem> BookLiberatedItems { get; } = new();
public AvaloniaList<liberatedComboBoxItem> BookLiberatedItems { get; } = new();
public List<liberatedComboBoxItem> PdfLiberatedItems { get; } = new();
public liberatedComboBoxItem PdfLiberatedSelectedItem { get; set; }
public liberatedComboBoxItem BookLiberatedSelectedItem
{
get => _bookLiberatedSelectedItem;
set
{
_bookLiberatedSelectedItem = value;
if (value?.Status is not LiberatedStatus.Error)
{
BookLiberatedItems.Remove(BookLiberatedItems.SingleOrDefault(s => s.Status == LiberatedStatus.Error));
}
}
}
public liberatedComboBoxItem BookLiberatedSelectedItem { get; set; }
public ICommand OpenInAudibleCommand { get; }
public BookDetailsDialogViewModel(LibraryBook libraryBook)
{
var Book = libraryBook.Book;
var locale = AudibleApi.Localization.Get(libraryBook.Book.Locale);
var link = $"https://www.audible.{locale.TopDomain}/pd/{libraryBook.Book.AudibleProductId}";
OpenInAudibleCommand = ReactiveCommand.Create(() => Go.To.Url(link));
IsSpatial = libraryBook.Book.IsSpatial;
//init tags
Tags = libraryBook.Book.UserDefinedItem.Tags;
@@ -118,16 +115,16 @@ namespace LibationAvalonia.Dialogs
var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}";
//init book details
DetailsText = @$"
Title: {title}
Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}
".Trim();
DetailsText = $"""
Title: {title}
Author(s): {Book.AuthorNames}
Narrator(s): {Book.NarratorNames}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}
""";
var seriesNames = libraryBook.Book.SeriesNames();
if (!string.IsNullOrWhiteSpace(seriesNames))

View File

@@ -8,14 +8,6 @@
Title="BookRecordsDialog">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="True"

View File

@@ -204,9 +204,8 @@ namespace LibationAvalonia.Dialogs
private class BookRecordEntry : ViewModels.ViewModelBase
{
private const string DateFormat = "yyyy-MM-dd HH\\:mm";
private bool _ischecked;
public IRecord Record { get; }
public bool IsChecked { get => _ischecked; set => this.RaiseAndSetIfChanged(ref _ischecked, value); }
public bool IsChecked { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string Type => Record.GetType().Name;
public string Start => formatTimeSpan(Record.Start);
public string Created => Record.Created.ToString(DateFormat);

View File

@@ -1,13 +1,15 @@
using Avalonia;
using Avalonia.Controls;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class DescriptionDisplayDialog : Window
{
public Point SpawnLocation { get; set; }
public string DescriptionText { get; init; }
public string? DescriptionText { get; init; }
public DescriptionDisplayDialog()
{
InitializeComponent();
@@ -17,15 +19,15 @@ namespace LibationAvalonia.Dialogs
Opened += DescriptionDisplay_Opened;
}
private void DescriptionDisplay_Opened(object sender, EventArgs e)
private void DescriptionDisplay_Opened(object? sender, EventArgs e)
{
DescriptionTextBox.Focus();
}
private void DescriptionDisplay_Activated(object sender, EventArgs e)
private void DescriptionDisplay_Activated(object? sender, EventArgs e)
{
DataContext = this;
var workingHeight = this.Screens.Primary.WorkingArea.Height;
var workingHeight = (Screens.ScreenFromTopLevel(this) ?? Screens.Primary ?? Screens.All.FirstOrDefault())?.WorkingArea.Height ?? 1080;
DescriptionTextBox.Measure(new Size(DescriptionTextBox.MinWidth, workingHeight * 0.8));
this.Width = DescriptionTextBox.DesiredSize.Width;

View File

@@ -9,14 +9,6 @@
Title="Edit Quick Filters"
x:DataType="dialogs:EditQuickFilters">
<Grid RowDefinitions="*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<DataGrid
Grid.Row="0"
CanUserReorderColumns="False"
@@ -108,7 +100,7 @@
<Button
Grid.Column="1"
Padding="30,5"
Classes="SaveButton"
Name="saveBtn"
Content="Save"
Command="{Binding SaveAndClose}" />

View File

@@ -13,29 +13,25 @@ namespace LibationAvalonia.Dialogs
public class Filter : ViewModels.ViewModelBase
{
private string _name;
public string Name
{
get => _name;
set => this.RaiseAndSetIfChanged(ref _name, value);
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
private string _filterString;
public string FilterString
{
get => _filterString;
get => field;
set
{
IsDefault = string.IsNullOrEmpty(value);
this.RaiseAndSetIfChanged(ref _filterString, value);
this.RaiseAndSetIfChanged(ref field, value);
this.RaisePropertyChanged(nameof(IsDefault));
}
}
public bool IsDefault { get; private set; } = true;
private bool _isTop;
private bool _isBottom;
public bool IsTop { get => _isTop; set => this.RaiseAndSetIfChanged(ref _isTop, value); }
public bool IsBottom { get => _isBottom; set => this.RaiseAndSetIfChanged(ref _isBottom, value); }
public bool IsTop { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public bool IsBottom { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name);
@@ -66,8 +62,11 @@ namespace LibationAvalonia.Dialogs
ControlToFocusOnShow = this.FindControl<Button>(nameof(saveBtn));
var allFilters = QuickFilters.Filters.Select(f => new Filter { FilterString = f.Filter, Name = f.Name }).ToList();
allFilters[0].IsTop = true;
allFilters[^1].IsBottom = true;
if (allFilters.Count > 0)
{
allFilters[0].IsTop = true;
allFilters[^1].IsBottom = true;
}
allFilters.Add(new Filter());
foreach (var f in allFilters)

View File

@@ -2,92 +2,80 @@
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="500" d:DesignHeight="450"
MinWidth="500" MinHeight="450"
Width="500" Height="450"
mc:Ignorable="d" d:DesignWidth="450" d:DesignHeight="450"
MinWidth="450" MinHeight="450"
Width="450" Height="450"
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
x:DataType="dialogs:EditReplacementChars"
Title="Illegal Character Replacement">
<Grid
RowDefinitions="*,Auto"
ColumnDefinitions="*,Auto">
x:CompileBindings="True"
Title="File Path Character Replacement">
<Grid RowDefinitions="*,Auto">
<DataGrid
Grid.Row="0"
Grid.ColumnSpan="2"
GridLinesVisibility="All"
Margin="5"
Name="replacementGrid"
AutoGenerateColumns="False"
IsReadOnly="False"
BeginningEdit="ReplacementGrid_BeginningEdit"
CellEditEnding="ReplacementGrid_CellEditEnding"
KeyDown="ReplacementGrid_KeyDown"
ItemsSource="{CompiledBinding replacements}">
GridLinesVisibility="All"
CanUserSortColumns="False"
AutoGenerateColumns="False"
ItemsSource="{Binding Replacements}"
KeyDown="replacementGrid_KeyDown"
BeginningEdit="replacementGrid_BeginningEdit"
CellEditEnded="replacementGrid_CellEditEnded"
CellEditEnding="replacementGrid_CellEditEnding">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Char to&#xa;Replace">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding CharacterToReplace, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Replacement&#xa;Text">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox Text="{CompiledBinding ReplacementText, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<controls:DataGridTextColumnExt
x:DataType="dialogs:EditReplacementChars+ReplacementsExt"
MaxLength="1"
Header="Char to&#xa;Replace"
Binding="{Binding CharacterToReplace, Mode=TwoWay}"/>
<DataGridTemplateColumn Width="*" Header="Description">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding Description, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTextColumn
x:DataType="dialogs:EditReplacementChars+ReplacementsExt"
Header="Replacement&#xa;Text"
Binding="{Binding ReplacementText, Mode=TwoWay}"/>
<DataGridTextColumn
x:DataType="dialogs:EditReplacementChars+ReplacementsExt"
Header="Description"
Binding="{Binding Description, Mode=TwoWay}"/>
</DataGrid.Columns>
</DataGrid>
<Grid
Grid.Row="1"
Grid.Column="0"
RowDefinitions="Auto,Auto"
Margin="5"
ColumnDefinitions="Auto,Auto,Auto,Auto">
ColumnDefinitions="Auto,Auto,Auto,*,Auto,Auto"
Margin="5">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="Margin" Value="2"/>
<Setter Property="Padding" Value="6"/>
<Setter Property="VerticalAlignment" Value="Bottom"/>
</Style>
<Style Selector="TextBlock">
<Setter Property="VerticalAlignment" Value="Center"/>
</Style>
</Grid.Styles>
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Text="This System:" Margin="0,0,10,0" VerticalAlignment="Center" />
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Text="NTFS:" Margin="0,0,10,0" VerticalAlignment="Center" />
<TextBlock Grid.Row="0" Text="This&#xa;System:" IsVisible="{Binding !EnvironmentIsWindows}" />
<TextBlock Grid.Row="1" Text="NTFS:" IsVisible="{Binding !EnvironmentIsWindows}" />
<Button Grid.Column="1" Margin="0,0,10,0" Command="{CompiledBinding Defaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Defaults" />
<Button Grid.Column="2" Margin="0,0,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="LoFi Defaults" />
<Button Grid.Column="3" Command="{CompiledBinding Barebones}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Barebones" />
<Button Grid.Column="1" Command="{Binding Defaults}" CommandParameter="{Binding EnvironmentIsWindows}" Content="Defaults" />
<Button Grid.Column="2" Command="{Binding LoFiDefaults}" CommandParameter="{Binding EnvironmentIsWindows}" Content="LoFi Defaults" />
<Button Grid.Column="3" Command="{Binding Barebones}" CommandParameter="{Binding EnvironmentIsWindows}" Content="Barebones" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="1" Margin="0,10,10,0" Command="{CompiledBinding Defaults}" CommandParameter="True" Content="Defaults" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="2" Margin="0,10,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="True" Content="LoFi Defaults" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="3" Margin="0,10,0,0" Command="{CompiledBinding Barebones}" CommandParameter="True" Content="Barebones" />
</Grid>
<StackPanel
Grid.Row="1"
Grid.Column="1"
Margin="5"
VerticalAlignment="Bottom"
Orientation="Horizontal">
<Button Margin="0,0,10,0" Command="{Binding Close}" Content="Cancel" />
<Button Padding="20,5,20,6" Command="{Binding SaveAndClose}" Content="Save" />
</StackPanel>
</Grid>
<Button Grid.Row="1" Grid.Column="1" IsVisible="{Binding !EnvironmentIsWindows}" Command="{Binding Defaults}" CommandParameter="True" Content="Defaults" />
<Button Grid.Row="1" Grid.Column="2" IsVisible="{Binding !EnvironmentIsWindows}" Command="{Binding LoFiDefaults}" CommandParameter="True" Content="LoFi Defaults" />
<Button Grid.Row="1" Grid.Column="3" IsVisible="{Binding !EnvironmentIsWindows}" Command="{Binding Barebones}" CommandParameter="True" Content="Barebones" />
<Button Grid.RowSpan="2" Grid.Column="4" Command="{Binding Close}" Content="Cancel" />
<Button Grid.RowSpan="2" Grid.Column="5" Classes="SaveButton" Command="{Binding SaveAndClose}" Content="Save" />
</Grid>
</Grid>
</Window>

View File

@@ -1,27 +1,27 @@
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Data;
using FileManager;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class EditReplacementChars : DialogWindow
{
Configuration config;
private Configuration? Config { get; }
public bool EnvironmentIsWindows => Configuration.IsWindows;
private readonly List<ReplacementsExt> SOURCE = new();
public DataGridCollectionView replacements { get; }
private readonly AvaloniaList<ReplacementsExt> SOURCE = new();
public DataGridCollectionView Replacements { get; }
public EditReplacementChars()
{
InitializeComponent();
replacements = new(SOURCE);
Replacements = new(SOURCE);
if (Design.IsDesignMode)
{
@@ -33,7 +33,7 @@ namespace LibationAvalonia.Dialogs
public EditReplacementChars(Configuration config) : this()
{
this.config = config;
Config = config;
LoadTable(config.ReplacementCharacters.Replacements);
}
@@ -44,15 +44,14 @@ namespace LibationAvalonia.Dialogs
public void Barebones(bool isNtfs)
=> LoadTable(ReplacementCharacters.Barebones(isNtfs).Replacements);
protected override void SaveAndClose()
public new void Close() => base.Close();
public new void SaveAndClose()
{
var replacements = SOURCE
.Where(r => !r.IsDefault)
.Select(r => new Replacement(r.Character, r.ReplacementText, r.Description) { Mandatory = r.Mandatory })
.ToList();
if (config is not null)
config.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements };
if (Config is not null)
{
var replacements = SOURCE.Where(r => !r.IsDefault).Select(r => r.ToReplacement()).ToArray();
Config.ReplacementCharacters = new ReplacementCharacters { Replacements = replacements };
}
base.SaveAndClose();
}
@@ -61,111 +60,90 @@ namespace LibationAvalonia.Dialogs
SOURCE.Clear();
SOURCE.AddRange(replacements.Select(r => new ReplacementsExt(r)));
SOURCE.Add(new ReplacementsExt());
this.replacements.Refresh();
}
public void ReplacementGrid_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
private bool ColumnIsCharacter(DataGridColumn column)
=> column.DisplayIndex is 0;
private bool ColumnIsReplacement(DataGridColumn column)
=> column.DisplayIndex is 1;
private bool RowIsReadOnly(DataGridRow row)
=> row.DataContext is ReplacementsExt rep && rep.Mandatory;
private bool CanDeleteSelectedItem(ReplacementsExt selectedItem)
=> !selectedItem.Mandatory && (!selectedItem.IsDefault || SOURCE[^1] != selectedItem);
private void replacementGrid_BeginningEdit(object sender, DataGridBeginningEditEventArgs e)
{
if (e.Key == Avalonia.Input.Key.Delete
&& ((DataGrid)sender).SelectedItem is ReplacementsExt repl
&& !repl.Mandatory
&& !repl.IsDefault)
{
replacements.Remove(repl);
}
e.Cancel = RowIsReadOnly(e.Row) && !ColumnIsReplacement(e.Column);
}
public void ReplacementGrid_CellEditEnding(object sender, DataGridCellEditEndingEventArgs e)
private void replacementGrid_CellEditEnding(object? sender, DataGridCellEditEndingEventArgs e)
{
var replacement = e.Row.DataContext as ReplacementsExt;
var colBinding = columnBindingPath(e.Column);
//Prevent duplicate CharacterToReplace
if (e.EditingElement is TextBox tbox
&& colBinding == nameof(replacement.CharacterToReplace)
&& SOURCE.Any(r => r != replacement && r.CharacterToReplace == tbox.Text))
//Disallow duplicates of CharacterToReplace
if (ColumnIsCharacter(e.Column) && e.Row.DataContext is ReplacementsExt r && r.CharacterToReplace.Length > 0 && SOURCE.Count(rep => rep.CharacterToReplace == r.CharacterToReplace) > 1)
{
tbox.Text = replacement.CharacterToReplace;
}
//Add new blank row
void Replacement_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!SOURCE.Any(r => r.IsDefault))
{
var rewRepl = new ReplacementsExt();
SOURCE.Add(rewRepl);
}
replacement.PropertyChanged -= Replacement_PropertyChanged;
}
replacement.PropertyChanged += Replacement_PropertyChanged;
}
public void ReplacementGrid_BeginningEdit(object sender, DataGridBeginningEditEventArgs e)
{
var replacement = e.Row.DataContext as ReplacementsExt;
//Disallow editing of Mandatory CharacterToReplace and Descriptions
if (replacement.Mandatory
&& columnBindingPath(e.Column) != nameof(replacement.ReplacementText))
r.CharacterToReplace = "";
e.Cancel = true;
}
}
private static string columnBindingPath(DataGridColumn column)
=> ((Binding)((DataGridBoundColumn)column).Binding).Path;
private void replacementGrid_CellEditEnded(object? sender, DataGridCellEditEndedEventArgs e)
{
if (ColumnIsCharacter(e.Column) && e.Row.DataContext is ReplacementsExt r && r.CharacterToReplace.Length > 0 && !SOURCE[^1].IsDefault)
{
Replacements.AddNew();
}
}
private void replacementGrid_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
{
if (e.Key == Avalonia.Input.Key.Delete && (sender as DataGrid)?.SelectedItem is ReplacementsExt r && CanDeleteSelectedItem(r))
{
if (Replacements.IsEditingItem)
{
if (Replacements.CanCancelEdit)
Replacements.CancelEdit();
else
Replacements.CommitEdit();
}
if (Replacements.IsAddingNew)
{
Replacements.CancelNew();
}
if (Replacements.CanRemove)
{
Replacements.Remove(r);
}
}
}
public class ReplacementsExt : ViewModels.ViewModelBase
{
public ReplacementsExt()
{
_replacementText = string.Empty;
_description = string.Empty;
_characterToReplace = string.Empty;
IsDefault = true;
ReplacementText = string.Empty;
Description = string.Empty;
CharacterToReplace = string.Empty;
}
public ReplacementsExt(Replacement replacement)
{
_characterToReplace = replacement.CharacterToReplace == default ? "" : replacement.CharacterToReplace.ToString();
_replacementText = replacement.ReplacementString;
_description = replacement.Description;
CharacterToReplace = replacement.CharacterToReplace == default ? "" : replacement.CharacterToReplace.ToString();
ReplacementText = replacement.ReplacementString;
Description = replacement.Description;
Mandatory = replacement.Mandatory;
}
private string _replacementText;
private string _description;
private string _characterToReplace;
public string ReplacementText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string Description { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string CharacterToReplace { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public char Character => string.IsNullOrEmpty(CharacterToReplace) ? default : CharacterToReplace[0];
public bool IsDefault => !Mandatory && string.IsNullOrEmpty(CharacterToReplace);
public bool Mandatory { get; }
public string ReplacementText
{
get => _replacementText;
set
{
if (ReplacementCharacters.ContainsInvalidFilenameChar(value))
this.RaisePropertyChanged(nameof(ReplacementText));
else
this.RaiseAndSetIfChanged(ref _replacementText, value);
}
}
public string Description { get => _description; set => this.RaiseAndSetIfChanged(ref _description, value); }
public string CharacterToReplace
{
get => _characterToReplace;
set
{
if (value?.Length != 1)
this.RaisePropertyChanged(nameof(CharacterToReplace));
else
{
IsDefault = false;
this.RaiseAndSetIfChanged(ref _characterToReplace, value);
}
}
}
public char Character => string.IsNullOrEmpty(_characterToReplace) ? default : _characterToReplace[0];
public bool IsDefault { get; private set; }
public Replacement ToReplacement()
=> new(Character, ReplacementText, Description) { Mandatory = Mandatory };
}
}
}

View File

@@ -110,7 +110,7 @@
Command="{Binding GoToNamingTemplateWiki}" />
<Button
Grid.Row="2"
Padding="30,5,30,5"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Click="SaveButton_Click" />

View File

@@ -26,7 +26,7 @@ public partial class EditTemplateDialog : DialogWindow
if (Design.IsDesignMode)
{
var mockInstance = Configuration.CreateMockInstance();
mockInstance.Books = Configuration.DefaultBooksDirectory;
mockInstance.Books = "/Path/To/Books";
RequestedThemeVariant = ThemeVariant.Dark;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(mockInstance.Books, mockInstance.FileTemplate);
_viewModel = new(mockInstance, editor);
@@ -101,19 +101,17 @@ public partial class EditTemplateDialog : DialogWindow
=> Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md");
// hold the work-in-progress value. not guaranteed to be valid
private string _userTemplateText;
public string UserTemplateText
{
get => _userTemplateText;
get => field;
set
{
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
this.RaiseAndSetIfChanged(ref field, value);
templateTb_TextChanged();
}
}
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public string WarningText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string Description { get; }

View File

@@ -61,15 +61,7 @@ namespace LibationAvalonia.Dialogs
public class BitmapHolder : ViewModels.ViewModelBase
{
private Bitmap _coverImage;
public Bitmap CoverImage
{
get => _coverImage;
set
{
this.RaiseAndSetIfChanged(ref _coverImage, value);
}
}
public Bitmap CoverImage { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}
}

View File

@@ -25,7 +25,7 @@
Grid.Row="1"
HorizontalAlignment="Right"
Margin="5"
Padding="30,3,30,3"
Classes="SaveButton"
Content="Save"
Click="Save_Click" />
</Grid>

View File

@@ -1,10 +1,12 @@
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System.Collections.Generic;
using System.IO;
namespace LibationAvalonia.Dialogs
{
public partial class LibationFilesDialog : DialogWindow
public partial class LibationFilesDialog : DialogWindow, ILibationInstallLocation
{
private class DirSelectOptions
{
@@ -15,7 +17,7 @@ namespace LibationAvalonia.Dialogs
Configuration.KnownDirectories.MyDocs
};
public string Directory { get; set; } = Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
public string Directory { get; set; }
}
private readonly DirSelectOptions dirSelectOptions;
@@ -28,12 +30,29 @@ namespace LibationAvalonia.Dialogs
DataContext = dirSelectOptions = new();
}
public LibationFilesDialog(string initialDir)
{
dirSelectOptions = new();
dirSelectOptions.Directory = Directory.Exists(initialDir) ? initialDir : Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
InitializeComponent();
DataContext = dirSelectOptions;
}
public async void Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!System.IO.Directory.Exists(SelectedDirectory))
{
await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + SelectedDirectory, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false);
return;
try
{
Directory.CreateDirectory(SelectedDirectory);
}
catch
{
await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + SelectedDirectory, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false);
return;
}
}
await SaveAndCloseAsync();

View File

@@ -37,7 +37,8 @@
<Button
Grid.Row="1"
Padding="30,5"
Classes="SaveButton"
Name="SaveButton"
HorizontalAlignment="Right"
Content="Save"
Command="{Binding SaveAndClose}"/>

View File

@@ -9,6 +9,7 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
DataContext = this;
ControlToFocusOnShow = SaveButton;
}
}
}

View File

@@ -53,10 +53,11 @@
Grid.Row="1"
Grid.Column="1"
Margin="10,0"
Padding="30,5"
Classes="SaveButton"
VerticalAlignment="Stretch"
HorizontalAlignment="Right"
Content="Save"
Name="SaveButton"
Click="SaveButton_Clicked" />
</Grid>
</Window>

View File

@@ -44,6 +44,7 @@ namespace LibationAvalonia.Dialogs
InitializeComponent();
SelectedItem = BookStatuses[0] as liberatedComboBoxItem;
DataContext = this;
ControlToFocusOnShow = SaveButton;
}
public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)

View File

@@ -81,17 +81,15 @@ namespace LibationAvalonia.Dialogs
return;
}
using var context = DbContexts.GetContext();
await foreach (var book in AudioFileStorage.FindAudiobooksAsync(selectedFolder, tokenSource.Token))
{
try
{
FilePathCache.Insert(book);
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
var lb = DbContexts.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb is not null && lb.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
await lb.UpdateBookStatusAsync(LiberatedStatus.Liberated);
tokenSource.Token.ThrowIfCancellationRequested();
FileFound?.Invoke(this, book);
@@ -110,8 +108,7 @@ namespace LibationAvalonia.Dialogs
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); }
public int FoundAsins { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}

View File

@@ -1,6 +1,7 @@
using AudibleApi;
using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Platform;
using Avalonia.Threading;
using LibationFileManager;
using LibationUiBase.Forms;
@@ -28,7 +29,7 @@ namespace LibationAvalonia.Dialogs.Login
{
try
{
if (await BrowserLoginAsync(choiceIn.LoginUrl) is ChoiceOut external)
if (Configuration.Instance.UseWebView && await BrowserLoginAsync(choiceIn) is ChoiceOut external)
return external;
}
catch (Exception ex)
@@ -42,21 +43,19 @@ namespace LibationAvalonia.Dialogs.Login
: null;
}
private async Task<ChoiceOut?> BrowserLoginAsync(string url)
private async Task<ChoiceOut?> BrowserLoginAsync(ChoiceIn shoiceIn)
{
TaskCompletionSource<ChoiceOut?> tcs = new();
NativeWebDialog dialog = new()
{
Title = "Audible Login",
CanUserResize = true,
Source = new Uri(url)
CanUserResize = true
};
dialog.AdapterCreated += Dialog_AdapterCreated;
dialog.EnvironmentRequested += Dialog_EnvironmentRequested;
dialog.NavigationCompleted += Dialog_NavigationCompleted;
dialog.Closing += (_, _) => tcs.TrySetResult(null);
dialog.NavigationStarted += (_, e) =>
dialog.NavigationStarted += async (_, e) =>
{
if (e.Request?.AbsolutePath.StartsWith("/ap/maplanding") is true)
{
@@ -64,6 +63,16 @@ namespace LibationAvalonia.Dialogs.Login
dialog.Close();
}
};
dialog.AdapterCreated += (s, e) =>
{
if (dialog.TryGetCookieManager() is NativeWebViewCookieManager cookieManager)
{
foreach (System.Net.Cookie c in shoiceIn.SignInCookies)
cookieManager.AddOrUpdateCookie(c);
}
//Set the source only after loading cookies
dialog.Source = new Uri(shoiceIn.LoginUrl);
};
if (!Configuration.IsLinux && App.MainWindow is TopLevel topLevel)
dialog.Show(topLevel);
@@ -71,7 +80,27 @@ namespace LibationAvalonia.Dialogs.Login
dialog.Show();
return await tcs.Task;
}
void Dialog_EnvironmentRequested(object? sender, WebViewEnvironmentRequestedEventArgs e)
{
// Private browsing & user agent setting
switch (e)
{
case WindowsWebView2EnvironmentRequestedEventArgs webView2Args:
webView2Args.IsInPrivateModeEnabled = true;
webView2Args.AdditionalBrowserArguments = "--user-agent=\"" + Resources.User_Agent + "\"";
break;
case AppleWKWebViewEnvironmentRequestedEventArgs appleArgs:
appleArgs.NonPersistentDataStore = true;
appleArgs.ApplicationNameForUserAgent = Resources.User_Agent;
break;
case GtkWebViewEnvironmentRequestedEventArgs gtkArgs:
gtkArgs.EphemeralDataManager = true;
gtkArgs.ApplicationNameForUserAgent = Resources.User_Agent;
break;
}
}
}
private async void Dialog_NavigationCompleted(object? sender, WebViewNavigationCompletedEventArgs e)
{
@@ -81,15 +110,6 @@ namespace LibationAvalonia.Dialogs.Login
}
}
private void Dialog_AdapterCreated(object? sender, WebViewAdapterEventArgs e)
{
if ((sender as NativeWebDialog)?.TryGetWindow() is Window window)
{
window.Width = 450;
window.Height = 700;
}
}
private static string getScript(string accountID) => $$"""
(function() {
function populateForm(){

View File

@@ -102,7 +102,8 @@
<Button
Grid.Row="1"
Margin="0,5,0,0"
Padding="30,3,30,3" HorizontalAlignment="Right"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Submit"
Click="Submit_Click" />
</Grid>

View File

@@ -64,11 +64,10 @@
<Button
Grid.Row="3"
Height="30"
HorizontalAlignment="Center"
Margin="10,10,10,10"
Padding="30,3,30,3"
Margin="10"
Name="OkButton"
Classes="SaveButton"
Content="Ok"
Click="OkButton_Clicked" />
</Grid>

View File

@@ -52,7 +52,7 @@ namespace LibationAvalonia.Dialogs
LongPath dir = "";
try
{
dir = LibationFileManager.Configuration.Instance.LibationFiles;
dir = LibationFileManager.Configuration.Instance.LibationFiles.Location;
Go.To.Folder(dir.ShortPathName);
}
catch

View File

@@ -36,12 +36,6 @@
</DockPanel>
<DockPanel Height="45" Grid.Row="1" Background="{DynamicResource SystemChromeMediumLowColor}">
<DockPanel.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</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" HorizontalAlignment="Center" Text="{CompiledBinding Button1Text}"/>

View File

@@ -1,75 +1,59 @@
<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="500" d:DesignHeight="200"
x:Class="LibationAvalonia.Dialogs.ScanAccountsDialog"
MinWidth="500" MinHeight="160"
Width="500" Height="200"
Title="Which Accounts?"
WindowStartupLocation="CenterOwner">
<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="500" d:DesignHeight="340"
MinWidth="200" MinHeight="210"
Width="500" Height="500"
x:Class="LibationAvalonia.Dialogs.ScanAccountsDialog"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:ScanAccountsDialog"
x:CompileBindings="True"
Title="Which Accounts?"
WindowStartupLocation="CenterOwner">
<Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,*,Auto">
<Grid.Styles>
<Style Selector="Button:focus">
<Setter Property="BorderBrush" Value="{DynamicResource SystemAccentColor}" />
<Setter Property="BorderThickness" Value="2" />
</Style>
</Grid.Styles>
<Grid
ColumnDefinitions="*,Auto"
RowDefinitions="Auto,*,Auto"
Margin="10">
<TextBlock
Grid.Row="0"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="10"
Text="Check the accounts to scan and import.&#xa;To change default selections, go to: Settings > Accounts"/>
<ScrollViewer
<DockPanel
Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="2"
Margin="10,0"
VerticalAlignment="Stretch"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
Margin="0,10"
VerticalAlignment="Stretch">
<ListBox ItemsSource="{Binding Accounts}">
<ListBox Name="lbAccounts" ItemsSource="{Binding Accounts}">
<ListBox.ItemTemplate>
<DataTemplate>
<StackPanel Height="20" Orientation="Horizontal">
<CheckBox
Margin="0,0,10,0"
IsChecked="{Binding IsChecked, Mode=TwoWay}" />
<TextBlock
FontSize="12"
VerticalAlignment="Center"
Text="{Binding Text}" />
</StackPanel>
<CheckBox
IsChecked="{Binding IsChecked, Mode=TwoWay}">
<TextBlock Text="{Binding Text}" />
</CheckBox>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</ListBox.ItemTemplate>
</ListBox>
</DockPanel>
<Button
Grid.Row="2"
Grid.Column="0"
Padding="20,5"
Margin="10"
Padding="20,6"
Content="Edit Accounts"
Command="{Binding EditAccountsAsync}"/>
<Button
Grid.Row="2"
Grid.Column="1"
Padding="30,5"
Margin="10"
Classes="SaveButton"
HorizontalAlignment="Right"
Content="Import"
Name="ImportButton"

View File

@@ -1,7 +1,6 @@
using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Collections;
using LibationUiBase.Forms;
using System.Collections;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
@@ -10,41 +9,36 @@ namespace LibationAvalonia.Dialogs
{
public partial class ScanAccountsDialog : DialogWindow
{
public List<Account> CheckedAccounts { get; } = new();
private List<listItem> _accounts { get; } = new();
public IList Accounts => _accounts;
private class listItem
public IEnumerable<Account> CheckedAccounts => Accounts.Where(a => a.IsChecked).Select(a => a.Account);
public AvaloniaList<ListItem> Accounts { get; } = new();
public class ListItem
{
public Account Account { get; set; }
public string Text { get; set; }
public bool IsChecked { get; set; } = true;
public ListItem(Account account)
{
Account = account;
IsChecked = account.LibraryScan;
Text = $"{account.AccountName} ({account.AccountId} - {account.Locale.Name})";
}
public Account Account { get; }
public string Text { get; }
public bool IsChecked { get; set; }
public override string ToString() => Text;
}
public ScanAccountsDialog()
{
InitializeComponent();
ControlToFocusOnShow = this.FindControl<Button>(nameof(ImportButton));
ControlToFocusOnShow = ImportButton;
DataContext = this;
LoadAccounts();
}
private void LoadAccounts()
{
_accounts.Clear();
Accounts.Clear();
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.Accounts;
foreach (var account in accounts)
_accounts.Add(new listItem
{
Account = account,
IsChecked = account.LibraryScan,
Text = $"{account.AccountName} ({account.AccountId} - {account.Locale.Name})"
});
DataContext = this;
Accounts.AddRange(accounts.Select(account => new ListItem(account)));
}
public async Task EditAccountsAsync()
@@ -56,12 +50,7 @@ namespace LibationAvalonia.Dialogs
}
}
protected override void SaveAndClose()
{
foreach (listItem item in _accounts.Where(a => a.IsChecked))
CheckedAccounts.Add(item.Account);
base.SaveAndClose();
}
public new void SaveAndClose()
=> base.SaveAndClose();
}
}

View File

@@ -50,25 +50,25 @@
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="STRING FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding StringUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding StringFields}"/>
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding StringFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="NUMBER FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding NumberUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding NumberFields}"/>
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding NumberFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Text="BOOLEAN (TRUE/FALSE) FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding BoolUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding BoolFields}"/>
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding BoolFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="3" RowDefinitions="Auto,Auto,*">
<TextBlock Text="ID FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding IdUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding IdFields}"/>
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding IdFields}"/>
</Grid>
</Grid>
</Window>

Some files were not shown because too many files have changed in this diff Show More