Compare commits

...

122 Commits

Author SHA1 Message Date
Robert McRackan
7474f1221a bug fix #441 -- new code with empty setting 2023-01-09 16:30:12 -05:00
Robert McRackan
915906e6ed test push ver 2023-01-09 11:59:24 -05:00
Robert McRackan
358c8b577e increment version 2023-01-09 11:53:44 -05:00
rmcrackan
5c450a01a4 Merge pull request #440 from Mbucari/master
Configuration Change Tracking and Bookk Records
2023-01-09 11:41:34 -05:00
Michael Bucari-Tovo
36264c6c6e Add back a check that was removed for testing 2023-01-08 13:19:21 -07:00
Michael Bucari-Tovo
fca946bf15 Fix bug with long folder templates 2023-01-08 10:01:30 -07:00
Michael Bucari-Tovo
452ceef285 Tweak 2023-01-07 23:27:28 -07:00
Michael Bucari-Tovo
7fafee804d Double clicking on template item adds it to the template. 2023-01-07 23:15:38 -07:00
Michael Bucari-Tovo
3a48479435 Typos and formatting 2023-01-07 18:41:34 -07:00
Michael Bucari-Tovo
cab8555ab5 Make UpgradeNotificationDialog a DialogWindow 2023-01-07 18:18:14 -07:00
Michael Bucari-Tovo
e3b7cbcc2a Add proper Upgrade form 2023-01-07 18:09:37 -07:00
Michael Bucari-Tovo
ed15614288 Improve character display in EditTemplateDialog 2023-01-07 14:33:49 -07:00
Michael Bucari-Tovo
acb6d1b335 Use new Inline controls to selectively style TextBlock text 2023-01-07 12:05:38 -07:00
Michael Bucari-Tovo
fe804796ab Use ContinueWith to set Rating changed value 2023-01-06 23:46:06 -07:00
Michael Bucari-Tovo
4725fe36d1 Add property changed filtering events to Configuration 2023-01-06 22:56:00 -07:00
Michael Bucari-Tovo
5c73beff4b Add PropertyChanged detection for Dictionary type settings 2023-01-06 19:46:55 -07:00
Michael Bucari-Tovo
1f7000c2c9 Add Configurations property change notifications 2023-01-06 16:50:20 -07:00
Mbucari
f09baa1318 Merge branch 'rmcrackan:master' into master 2023-01-05 23:41:27 -07:00
Michael Bucari-Tovo
7eaa03e43c Add clip and bookmark viewer and exporter 2023-01-05 23:40:39 -07:00
Robert McRackan
26099303fa update dependencies 2023-01-05 21:44:34 -05:00
Michael Bucari-Tovo
6417aee780 Add book records dialog 2023-01-05 17:02:39 -07:00
rmcrackan
f9deaba4c5 Merge pull request #438 from Mbucari/master
Linux and mac upgrade notification
2023-01-03 21:26:04 -05:00
Michael Bucari-Tovo
ddd6a3b279 Change args 2023-01-03 15:17:57 -07:00
Michael Bucari-Tovo
9359950666 Formatting 2023-01-03 15:00:29 -07:00
Michael Bucari-Tovo
d31b2a1b65 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-03 14:56:09 -07:00
Michael Bucari-Tovo
b89b4e0af4 Linux and mac upgrade notification 2023-01-03 14:55:58 -07:00
rmcrackan
cbcde027b3 Merge pull request #436 from Mbucari/master
Add download speed limit
2023-01-02 13:07:03 -05:00
Mbucari
d306e6bd22 Merge branch 'rmcrackan:master' into master 2023-01-02 02:54:09 -07:00
Michael Bucari-Tovo
9ec877999e Add download speed limit 2023-01-02 02:46:46 -07:00
rmcrackan
f4189bf409 Merge pull request #432 from Mbucari/master
Add ability for users to edit book ratings from the main grid
2023-01-01 22:34:06 -05:00
Michael Bucari-Tovo
0ed5062683 Cancel rating edit on escape 2023-01-01 11:23:22 -07:00
Michael Bucari-Tovo
7ef666dc91 Add TCOM tag for narrator 2022-12-31 23:31:24 -07:00
Michael Bucari-Tovo
1ac825919a Remove old migrations 2022-12-31 22:41:11 -07:00
Michael Bucari-Tovo
a7bf30954d Fix null file bug and add context menu to my ratings column 2022-12-31 21:09:30 -07:00
Michael Bucari-Tovo
613cfdd903 Automatic refiltering now works on chardonnay 2022-12-31 19:50:49 -07:00
Michael Bucari-Tovo
28802c8279 Refilter on search update 2022-12-31 18:41:55 -07:00
Michael Bucari-Tovo
6d7b3bd5f0 Improve star display on classic 2022-12-31 15:39:46 -07:00
Michael Bucari-Tovo
b97d8e9403 Add ratings cell tool tips 2022-12-31 11:12:04 -07:00
Michael Bucari-Tovo
b4838d364e Only show hollow stars in editing mode 2022-12-31 10:33:18 -07:00
Michael Bucari-Tovo
05ac5c63e1 Formatting 2022-12-31 10:16:54 -07:00
Michael Bucari-Tovo
874bf9e7c0 Improve classic and chardonnay rating editor simmilarity 2022-12-31 10:02:30 -07:00
Michael Bucari-Tovo
c9497ef39e Make my rating column sortable 2022-12-30 19:44:16 -07:00
Michael Bucari-Tovo
496830d01d Fix cell editor to work with desktop scaling 2022-12-30 19:25:11 -07:00
Mbucari
ccebcdd4c7 Merge branch 'rmcrackan:master' into master 2022-12-30 17:01:22 -07:00
Michael Bucari-Tovo
c900fe8461 Add user rating editing to grid 2022-12-30 17:00:40 -07:00
rmcrackan
a0158db37e Merge pull request #431 from Mbucari/master
Fix file naming template on unix systems (#430)
2022-12-30 11:05:09 -05:00
Robert McRackan
b8c26b01ad update dependencies 2022-12-30 09:58:51 -05:00
Mbucari
3a44bef0d9 Lowercase command is more linux-ey 2022-12-29 16:24:41 -07:00
Mbucari
57a4ee781b .deb package build script (#390) 2022-12-29 16:04:20 -07:00
=
e12f475850 Refactor 2022-12-29 15:39:48 -07:00
=
f822a23daa Linux and OSX directory length limits 2022-12-29 15:29:18 -07:00
=
6901b8be35 Fix file naming template on unix systems 2022-12-29 14:12:46 -07:00
Robert McRackan
83fb2cd1d0 New feature #430 : bulk set pdf-downloaded status 2022-12-29 09:33:32 -05:00
Robert McRackan
c98664d584 Bugfix #423 : Chardonnay updater fails when windows username has a space 2022-12-22 09:45:32 -05:00
Robert McRackan
d098be8b03 EF: move seed data into corresponding config class 2022-12-22 09:37:21 -05:00
Robert McRackan
3f6689d032 Test release before v9 2022-12-20 12:43:04 -05:00
rmcrackan
b4206fc203 Merge pull request #416 from pixil98/master
Linux runners and Docker image
2022-12-19 21:58:38 -05:00
rmcrackan
cfa4a0c07f Merge pull request #417 from Mbucari/master
Improve download cancellation
2022-12-19 13:42:24 -05:00
Michael Bucari-Tovo
357b220ace Suppress warnings 2022-12-19 09:48:18 -07:00
Michael Bucari-Tovo
47968304c9 Return Download to new background thread 2022-12-19 09:15:36 -07:00
Michael Bucari-Tovo
2024d5e116 Improve download cancellation. 2022-12-18 21:52:51 -07:00
pixil98
5ae2a99c14 Docker workflow (#7)
* Refactored workflows
* Added docker build to release
* Linux and MacOS now build on Linux
2022-12-18 17:57:37 -06:00
rmcrackan
7fd002d2c9 Merge pull request #413 from Mbucari/master
Update obsolete code and fix #347
2022-12-18 09:41:31 -05:00
Michael Bucari-Tovo
b7b7038244 Delete partially decrypted files from previous Libation instances 2022-12-17 12:30:26 -07:00
Michael Bucari-Tovo
b5519c4875 Add option for user to choose custom temp folder 2022-12-17 12:25:13 -07:00
Michael Bucari-Tovo
44feab9eb2 Update comments 2022-12-17 11:39:37 -07:00
Michael Bucari-Tovo
96c45c33e5 Refactor NetworkFileStream replace obsolete WebRequest 2022-12-17 11:31:51 -07:00
Michael Bucari-Tovo
36efbcb812 Replace deprecated file dialogs 2022-12-16 21:08:11 -07:00
Michael Bucari-Tovo
03f44b4e9c Fix IDE class grouping 2022-12-16 19:54:31 -07:00
rmcrackan
19860e9f09 Merge pull request #412 from Mbucari/master
Fix Character Replacements and Add More Useful Error Messages
2022-12-16 21:28:36 -05:00
Michael Bucari-Tovo
0701cb3970 Reorder tabs 2022-12-16 16:45:51 -07:00
Michael Bucari-Tovo
7d6000e3b6 Bring Hangover Chardonnay into feature parity with Classic (#409) 2022-12-16 16:41:24 -07:00
Mbucari
ef973ac56a Merge branch 'rmcrackan:master' into master 2022-12-16 08:55:08 -07:00
Robert McRackan
91a1033c52 makes slashes more clear 2022-12-16 09:48:00 -05:00
Robert McRackan
4197db6af9 Fix unit tests failing because of windows newlines 2022-12-16 09:27:44 -05:00
MBucari
210ab065c2 Make tests xplat 2022-12-15 23:04:27 -07:00
Mbucari
9cd10eca58 Merge branch 'rmcrackan:master' into master 2022-12-15 19:25:27 -07:00
Robert McRackan
ba676be46d update dependency 2022-12-15 21:06:26 -05:00
Mbucari
665a2e1866 Merge branch 'rmcrackan:master' into master 2022-12-15 16:22:43 -07:00
Michael Bucari-Tovo
94469cae3d Add better error messages for license denial #352 2022-12-15 16:22:25 -07:00
Michael Bucari-Tovo
a0dd2ccad6 Make filename character replacement more xplat and allow replacing any char, not just illegal. 2022-12-15 15:50:48 -07:00
Robert McRackan
b2cf837de7 Hangover. WinForms. Restore deleted books 2022-12-15 14:11:27 -05:00
Mbucari
80bcf60b5b Merge branch 'rmcrackan:master' into master 2022-12-14 15:40:42 -07:00
Robert McRackan
7ad0ab566a New feature: 'Remove' now removes forever. Removed books won't be re-added on next scan 2022-12-14 16:19:55 -05:00
Michael Bucari-Tovo
2b16e86c7b Fix character replacement for non-windows platforms. 2022-12-13 16:33:37 -07:00
Robert McRackan
f2ea02ae0b bugfix. file extension 2022-12-13 16:09:24 -05:00
Robert McRackan
f65cd39040 bug fix: keyboard shortcuts 2022-12-13 15:58:54 -05:00
Robert McRackan
5ca0d2a399 New feature #406 : Right Click Menu for Stop-Light Icon (Chardonnay UI) 2022-12-13 15:32:33 -05:00
Robert McRackan
d1528a095b New feature #406 : Right Click Menu for Stop-Light Icon (Classic UI) 2022-12-13 13:48:28 -05:00
rmcrackan
749173a463 Merge pull request #407 from Mbucari/master
Add dynamic context menus to products grid
2022-12-13 08:33:03 -05:00
Michael Bucari-Tovo
6fbd90a6b3 Fix hidden tag 2022-12-13 02:42:53 -07:00
Michael Bucari-Tovo
f39d272e6a Make reused ContextMenu static 2022-12-12 18:23:12 -07:00
Michael Bucari-Tovo
bb3854f512 Finishing touch 2022-12-12 17:21:41 -07:00
Michael Bucari-Tovo
e40daecfb8 Remove old static context menu 2022-12-12 17:12:44 -07:00
Michael Bucari-Tovo
3716ab9cb5 Merged 2022-12-12 17:11:40 -07:00
Michael Bucari-Tovo
0cc6d6337a Add dynamic context menus to main grid 2022-12-12 17:10:18 -07:00
Robert McRackan
ce711a36ba #398 - new feature: right-click, copy 2022-12-12 15:03:20 -05:00
rmcrackan
451af7bea9 Merge pull request #405 from Mbucari/master
Upgraded to Avalonia 11-Preview4
2022-12-12 13:18:06 -05:00
Michael Bucari-Tovo
63200592bf Ensure mandatory character replacements remain marked mandatory 2022-12-12 08:34:10 -07:00
Michael Bucari-Tovo
d165dfbeb5 Fix NRE 2022-12-12 08:24:18 -07:00
Michael Bucari-Tovo
eed3d84517 Add context menu 2022-12-11 19:21:49 -07:00
Michael Bucari-Tovo
ba7d890966 Update hangover 2022-12-11 18:33:57 -07:00
Michael Bucari-Tovo
5140fc63d9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-12-11 18:30:56 -07:00
Michael Bucari-Tovo
78509c07e0 Remove unused files 2022-12-11 18:30:48 -07:00
Mbucari
5084141215 Delete Source/Visual Studio 2022 directory 2022-12-11 18:23:00 -07:00
Michael Bucari-Tovo
3f2ac83474 Fix copying grid contents to clipboard 2022-12-11 17:50:15 -07:00
Michael Bucari-Tovo
58a0468728 Tidy up 2022-12-11 16:58:51 -07:00
Mbucari
8e13aa7513 Merge branch 'rmcrackan:master' into master 2022-12-11 15:47:24 -07:00
Michael Bucari-Tovo
48e2d91fc8 Implement Illegal Char Replace dialog in Avalonia 2022-12-11 15:47:04 -07:00
rmcrackan
a7f119217f Merge pull request #404 from pixil98/master
Fix Auto Updating
2022-12-11 09:12:10 -05:00
pixil98
865f2261fe Fix Auto Updating (#8)
Release workflow no longer includes the parent folder in the zip.
2022-12-10 00:30:08 -06:00
Michael Bucari-Tovo
dfedb23efd Refactor ProductsDisplay 2022-12-09 12:27:52 -07:00
Mbucari
c01e1c3e4b Merge branch 'rmcrackan:master' into master 2022-12-09 12:27:14 -07:00
Robert McRackan
ad8dac5fb0 more forgiving releaseindex windows regex 2022-12-08 08:17:17 -05:00
Mbucari
84e81b6218 Merge branch 'rmcrackan:master' into master 2022-12-07 19:13:22 -07:00
Robert McRackan
86efe631fe restore yaml 2022-12-07 13:49:05 -05:00
Robert McRackan
f5f1dc483b publish debugging. create new version 2022-12-07 13:34:30 -05:00
Robert McRackan
8aa4328c6c update dependencies 2022-12-07 13:09:02 -05:00
Michael Bucari-Tovo
a01a8c4b19 Update Avalonia to v.11-Preview-4 2022-12-07 10:15:12 -07:00
Robert McRackan
4b2387b621 update dependencies 2022-12-07 11:53:10 -05:00
Robert McRackan
74d16d8ef9 yaml releases don't run. comment out for now 2022-12-07 07:42:11 -05:00
rmcrackan
b1ea8f9fa7 Update GettingStarted.md
disclaimer: don't install in Program Files
2022-12-06 15:59:11 -05:00
206 changed files with 9191 additions and 4155 deletions

8
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
---
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

79
.github/workflows/build-linux.yml vendored Normal file
View File

@@ -0,0 +1,79 @@
# build-linux.yml
# Reusable workflow that builds the Linux and MacOS versions of Libation.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
jobs:
build:
runs-on: ubuntu-latest
strategy:
matrix:
os: [Linux, MacOS]
ui: [Avalonia]
release_name: [chardonnay]
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
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 -oP '(?<=<Version>).*(?=</Version)' ./Source/AppScaffolding/AppScaffolding.csproj)"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
run: |
osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')"
artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}"
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
tar -zcvf "../${artifact}.tar.gz" .
- name: Publish artifact
uses: actions/upload-artifact@v3
with:
name: ${{ steps.zip.outputs.artifact }}.tar.gz
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.tar.gz
if-no-files-found: error

82
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,82 @@
# build-windows.yml
# Reusable workflow that builds the Windows versions of Libation.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
jobs:
build:
runs-on: windows-latest
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
release_name: classic
prefix: Classic-
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
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 {
[xml]$appScaffolding = Get-Content -Path ./Source/AppScaffolding/AppScaffolding.csproj
$version = $appScaffolding.Project.PropertyGroup.Version
}
"version=$version" >> $env:GITHUB_OUTPUT
- name: Unit test
if: ${{ inputs.run_unit_tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
run: |
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${{ matrix.os }}-${{ matrix.release_name }}\*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v3
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
if-no-files-found: error

30
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# build.yml
# Reusable workflow that builds Libation for all platforms.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
jobs:
windows:
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
linux:
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}

46
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
# docker.yml
# Reusable workflow that builds a docker image for Libation.
---
name: docker
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
secrets:
docker_username:
required: true
docker_token:
required: true
env:
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
- name: Build and push
uses: docker/build-push-action@v3
with:
push: true
build-args: 'FOLDER_NAME=Linux-chardonnay'
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}

View File

@@ -1,44 +0,0 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
name: build
on:
workflow_call:
env:
DOTNET_CONFIGURATION: 'Release'
jobs:
build:
runs-on: windows-latest
strategy:
matrix:
os: [Linux, MacOS, Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
release_name: classic
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '7.x'
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Build
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}\Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LoadByOS\${{ matrix.os }}ConfigApp\${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS\Properties\${{ matrix.os }}ConfigApp\PublishProfiles\${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LibationCli\LibationCli.csproj -p:PublishProfile=LibationCli\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}\Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
- name: Publish artifact
uses: actions/upload-artifact@v3
with:
name: ${{ matrix.os }}-${{ matrix.release_name }}
path: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}/*
if-no-files-found: error

View File

@@ -1,66 +0,0 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
name: release
on:
push:
tags:
- 'v*'
env:
DOTNET_VERSION: '7' # The .NET SDK version to use
DOTNET_SOURCE: './Source'
DOTNET_CONFIGURATION: 'Release'
jobs:
build:
uses: ./.github/workflows/dotnet-build.yml
release:
needs: build
runs-on: ubuntu-latest
steps:
- name: Calculate version
id: version
run: |
export TAG=${{ github.ref_name }}
echo "version=${TAG#v}" >> $GITHUB_OUTPUT
- name: Download artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Rename artifacts
id: rename
working-directory: ./artifacts
run: |
for FILENAME in *; do mv ${FILENAME} Libation.${{ steps.version.outputs.version }}-${FILENAME,,}; done
mv Libation.${{ steps.version.outputs.version }}-windows-classic Classic-Libation.${{ steps.version.outputs.version }}-windows-classic
- name: Zip assets
working-directory: ./artifacts
run: |
for FILENAME in *; do zip -r ${FILENAME}.zip ${FILENAME}; done
mkdir ./assets
mv *.zip ./assets
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
with:
tag_name: ${{ github.ref }}
release_name: Libation ${{ steps.version.outputs.version }}
body: <Put a body here>
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
release_id: ${{ steps.create_release.outputs.id }}
assets_path: ./artifacts/assets

View File

@@ -1,19 +0,0 @@
# This workflow will build a .NET project
# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
name: validate
on:
push:
branches: [ "master" ]
pull_request:
branches: [ "master" ]
env:
DOTNET_VERSION: '7' # The .NET SDK version to use
DOTNET_SLN: './Source/Libation.sln'
DOTNET_CONFIGURATION: 'Release'
jobs:
build:
uses: ./.github/workflows/dotnet-build.yml

64
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,64 @@
# release.yml
# Builds and creates the release on any tags starting with a `v`
---
name: release
on:
push:
tags:
- 'v*'
jobs:
prerelease:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get tag version
id: get_version
run: |
export TAG='${{ github.ref_name }}'
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
docker:
needs: [prerelease]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prerelease.outputs.version }}
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
release:
needs: [prerelease,build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Create release
id: create_release
uses: actions/create-release@v1
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with:
tag_name: '${{ github.ref }}'
release_name: 'Libation ${{ steps.version.outputs.version }}'
body: <Put a body here>
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with:
release_id: '${{ steps.create_release.outputs.id }}'
assets_path: ./artifacts

14
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
# validate.yml
# Validates that Libation will build on a pull request or push to master.
---
name: validate
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
uses: ./.github/workflows/build.yml

View File

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

68
Docker/liberate.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Rewire echo to print date time
echo() {
if [[ -n $1 ]]; then
printf "$(date '+%F %T'): %s\n" "$1"
fi
}
# ################################
# Setup
# ################################
echo "Starting"
if [[ -z "${SLEEP_TIME}" ]]; then
echo "No sleep time passed in. Will run once and exit."
else
echo "Sleep time is set to ${SLEEP_TIME}"
fi
echo ""
# Check if the config directory is passed in, and there is no link to it then create the link.
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
echo "Linking config directory to the Libation config directory"
ln -s /config/ /root/Libation
fi
# If no config error and exit
if [ ! -d "/config" ]; then
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
exit 1
fi
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
FILE=/db/LibationContext.db
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
echo "Linking passed in Libation database from /db/ to the Libation config directory"
ln -s $FILE /config/LibationContext.db
fi
# Confirm we have a db in the config direcotry.
if [ ! -f "/config/LibationContext.db" ]; then
echo "ERROR: No Libation database detected, exiting."
exit 1
fi
# ################################
# Loop and liberate
# ################################
while true
do
echo ""
echo "Scanning accounts"
/libation/LibationCli scan
echo "Liberating books"
/libation/LibationCli liberate
echo ""
# Liberate only once if SLEEP_TIME was set to -1
if [ "${SLEEP_TIME}" = -1 ]; then
break
fi
echo "Sleeping for ${SLEEP_TIME}"
sleep "${SLEEP_TIME}"
done
echo "Exiting"

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
COPY Source /Source
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
FROM mcr.microsoft.com/dotnet/runtime:7.0
ENV SLEEP_TIME "30m"
# Sets the character set that will be used for folder and filenames when liberating
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
RUN mkdir /db /config /data
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
CMD ["./libation/liberate.sh"]

View File

@@ -24,7 +24,7 @@
* Windows
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder.
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
* [Ubuntu Linux (beta)](InstallOnLinux.md)
* [MacOS (beta)](InstallOnMac.md)

View File

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

View File

@@ -32,6 +32,9 @@ namespace AaxDecrypter
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile)
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(

View File

@@ -43,7 +43,21 @@ namespace AaxDecrypter
return false;
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Cleanup");

View File

@@ -49,6 +49,19 @@ namespace AaxDecrypter
}
//Step 4
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 5
Serilog.Log.Information("Begin Step 4: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 4: Cleanup");

View File

@@ -1,5 +1,4 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Dinah.Core;
@@ -49,10 +48,11 @@ namespace AaxDecrypter
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
}
}
public abstract Task CancelAsync();
@@ -72,7 +72,7 @@ namespace AaxDecrypter
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
@@ -111,8 +111,8 @@ namespace AaxDecrypter
{
FileUtility.SaferDelete(jsonDownloadState);
if (DownloadOptions.AudibleKey is not null &&
DownloadOptions.AudibleIV is not null &&
if (DownloadOptions.AudibleKey is not null &&
DownloadOptions.AudibleIV is not null &&
DownloadOptions.RetainEncryptedFile)
{
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
@@ -133,14 +133,27 @@ namespace AaxDecrypter
return success;
}
protected async Task<bool> Step_DownloadClipsBookmarks()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
if (!File.Exists(jsonDownloadState))
return NewNetworkFilePersister();
NetworkFileStreamPersister nfsp = default;
try
{
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
if (!File.Exists(jsonDownloadState))
return nfsp = NewNetworkFilePersister();
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
@@ -150,18 +163,18 @@ namespace AaxDecrypter
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(TempFilePath);
return NewNetworkFilePersister();
return nfsp = NewNetworkFilePersister();
}
finally
{
if (nfsp?.NetworkFileStream is not null)
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
}
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", DownloadOptions.UserAgent }
};
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
}

View File

@@ -1,9 +1,12 @@
using AAXClean;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
event EventHandler<long> DownloadSpeedChanged;
FileManager.ReplacementCharacters ReplacementCharacters { get; }
string DownloadUrl { get; }
string UserAgent { get; }
@@ -14,6 +17,8 @@ namespace AaxDecrypter
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
@@ -21,5 +26,6 @@ namespace AaxDecrypter
bool MatchSourceBitrate { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitleName(MultiConvertFileProperties props);
}
Task<string> SaveClipsAndBookmarks(string fileName);
}
}

View File

@@ -1,91 +1,58 @@
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace AaxDecrypter
{
/// <summary>
/// A <see cref="CookieContainer"/> for a single Uri.
/// </summary>
public class SingleUriCookieContainer : CookieContainer
{
private Uri baseAddress;
public Uri Uri
{
get => baseAddress;
set
{
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
}
}
public CookieCollection GetCookies()
{
return GetCookies(Uri);
}
}
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
#region Public Properties
/// <summary>
/// Location to save the downloaded data.
/// </summary>
/// <summary> Location to save the downloaded data. </summary>
[JsonProperty(Required = Required.Always)]
public string SaveFilePath { get; }
/// <summary>
/// Http(s) address of the file to download.
/// </summary>
/// <summary> Http(s) address of the file to download. </summary>
[JsonProperty(Required = Required.Always)]
public Uri Uri { get; private set; }
/// <summary>
/// All cookies set by caller or by the remote server.
/// </summary>
/// <summary> Http headers to be sent to the server with the request. </summary>
[JsonProperty(Required = Required.Always)]
public SingleUriCookieContainer CookieContainer { get; }
public Dictionary<string, string> RequestHeaders { get; private set; }
/// <summary>
/// Http headers to be sent to the server with the request.
/// </summary>
[JsonProperty(Required = Required.Always)]
public WebHeaderCollection RequestHeaders { get; private set; }
/// <summary>
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
/// </summary>
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
[JsonProperty(Required = Required.Always)]
public long WritePosition { get; private set; }
/// <summary>
/// The total length of the <see cref="Uri"/> file to download.
/// </summary>
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
[JsonProperty(Required = Required.Always)]
public long ContentLength { get; private set; }
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
#endregion
#region Private Properties
private HttpWebRequest HttpRequest { get; set; }
private FileStream _writeFile { get; }
private FileStream _readFile { get; }
private Stream _networkStream { get; set; }
private bool hasBegunDownloading { get; set; }
public bool IsCancelled { get; private set; }
private EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; }
private Task _backgroundDownloadTask { get; set; }
#endregion
@@ -98,19 +65,23 @@ namespace AaxDecrypter
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
//Number of times per second the download rate is checkd and throttled
private const int THROTTLE_FREQUENCY = 8;
//Minimum throttle rate. The minimum amount of data that can be throttled
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
#endregion
#region Constructor
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
@@ -122,8 +93,7 @@ namespace AaxDecrypter
SaveFilePath = saveFilePath;
Uri = uri;
WritePosition = writePosition;
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
RequestHeaders = requestHeaders ?? new();
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
{
@@ -139,12 +109,10 @@ namespace AaxDecrypter
#region Downloader
/// <summary>
/// Update the <see cref="JsonFilePersister"/>.
/// </summary>
/// <summary> Update the <see cref="JsonFilePersister"/>. </summary>
private void Update()
{
RequestHeaders = HttpRequest.Headers;
RequestHeaders["Range"] = $"bytes={WritePosition}-";
try
{
Updated?.Invoke(this, EventArgs.Empty);
@@ -155,9 +123,7 @@ namespace AaxDecrypter
}
}
/// <summary>
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
/// </summary>
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
public void SetUriForSameFile(Uri uriToSameFile)
{
@@ -165,37 +131,29 @@ namespace AaxDecrypter
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (hasBegunDownloading)
if (_backgroundDownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
HttpRequest = WebRequest.CreateHttp(Uri);
HttpRequest.CookieContainer = CookieContainer;
HttpRequest.Headers = RequestHeaders;
//If NetworkFileStream is resuming, Header will already contain a range.
HttpRequest.Headers.Remove("Range");
HttpRequest.AddRange(WritePosition);
RequestHeaders["Range"] = $"bytes={WritePosition}-";
}
/// <summary>
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
/// </summary>
private void BeginDownloading()
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
/// <returns>The downloader <see cref="Task"/></returns>
private Task BeginDownloading()
{
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
if (ContentLength != 0 && WritePosition == ContentLength)
{
hasBegunDownloading = true;
downloadEnded.Set();
return;
}
return Task.CompletedTask;
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var response = HttpRequest.GetResponse() as HttpWebResponse;
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
var response = new HttpClient().Send(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
@@ -203,24 +161,17 @@ namespace AaxDecrypter
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0)
ContentLength = response.ContentLength;
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
_networkStream = response.GetResponseStream();
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
var networkStream = response.Content.ReadAsStream(_cancellationSource.Token);
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
new Thread(() => DownloadFile())
{ IsBackground = true }
.Start();
hasBegunDownloading = true;
return;
return Task.Run(async () => await DownloadFile(networkStream), _cancellationSource.Token);
}
/// <summary>
/// Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.
/// </summary>
private void DownloadFile()
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
private async Task DownloadFile(Stream networkStream)
{
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
@@ -228,29 +179,44 @@ namespace AaxDecrypter
try
{
DateTime startTime = DateTime.Now;
long bytesReadSinceThrottle = 0;
int bytesRead;
do
{
bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
bytesRead = await networkStream.ReadAsync(buff, 0, DOWNLOAD_BUFF_SZ, _cancellationSource.Token);
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
{
_writeFile.Flush();
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
_downloadedPiece.Set();
}
#region throttle
bytesReadSinceThrottle += bytesRead;
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.Now;
bytesReadSinceThrottle = 0;
}
#endregion
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
@@ -258,14 +224,16 @@ namespace AaxDecrypter
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (Exception ex)
catch (TaskCanceledException)
{
Serilog.Log.Error(ex, "An error was encountered while downloading {Uri}", Uri);
Serilog.Log.Information("Download was cancelled");
}
finally
{
downloadedPiece.Set();
downloadEnded.Set();
networkStream.Close();
_writeFile.Close();
_downloadedPiece.Set();
Update();
}
}
@@ -274,96 +242,7 @@ namespace AaxDecrypter
#region Json Connverters
public static JsonSerializerSettings GetJsonSerializerSettings()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(new CookieContainerConverter());
settings.Converters.Add(new WebHeaderCollectionConverter());
return settings;
}
internal class CookieContainerConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(SingleUriCookieContainer);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new SingleUriCookieContainer()
{
Uri = new Uri(jObj["Uri"].Value<string>()),
Capacity = jObj["Capacity"].Value<int>(),
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
};
var cookieList = jObj["Cookies"].ToList();
foreach (var cookie in cookieList)
{
result.Add(
new Cookie
{
Comment = cookie["Comment"].Value<string>(),
HttpOnly = cookie["HttpOnly"].Value<bool>(),
Discard = cookie["Discard"].Value<bool>(),
Domain = cookie["Domain"].Value<string>(),
Expired = cookie["Expired"].Value<bool>(),
Expires = cookie["Expires"].Value<DateTime>(),
Name = cookie["Name"].Value<string>(),
Path = cookie["Path"].Value<string>(),
Port = cookie["Port"].Value<string>(),
Secure = cookie["Secure"].Value<bool>(),
Value = cookie["Value"].Value<string>(),
Version = cookie["Version"].Value<int>(),
});
}
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var cookies = value as SingleUriCookieContainer;
var obj = (JObject)JToken.FromObject(value);
var container = cookies.GetCookies();
var propertyNames = container.Select(c => JToken.FromObject(c));
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
obj.WriteTo(writer);
}
}
internal class WebHeaderCollectionConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(WebHeaderCollection);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new WebHeaderCollection();
foreach (var kvp in jObj)
result.Add(kvp.Key, kvp.Value.Value<string>());
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var jObj = new JObject();
var type = value.GetType();
var headers = value as WebHeaderCollection;
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
jObj.Add(jHeaders);
jObj.WriteTo(writer);
}
}
=> new JsonSerializerSettings();
#endregion
@@ -383,8 +262,7 @@ namespace AaxDecrypter
{
get
{
if (!hasBegunDownloading)
BeginDownloading();
_backgroundDownloadTask ??= BeginDownloading();
return ContentLength;
}
}
@@ -401,18 +279,17 @@ namespace AaxDecrypter
[JsonIgnore]
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
public override void Flush() => throw new NotImplementedException();
public override void SetLength(long value) => throw new NotImplementedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
public override void Flush() => throw new InvalidOperationException();
public override void SetLength(long value) => throw new InvalidOperationException();
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
public override int Read(byte[] buffer, int offset, int count)
{
if (!hasBegunDownloading)
BeginDownloading();
_backgroundDownloadTask ??= BeginDownloading();
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return _readFile.Read(buffer, offset, count);
return IsCancelled ? 0: _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
@@ -428,38 +305,32 @@ namespace AaxDecrypter
return _readFile.Position = newPosition;
}
/// <summary>
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
/// </summary>
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
&& hasBegunDownloading
&& !IsCancelled
&& !downloadEnded.WaitOne(0))
&& _backgroundDownloadTask?.IsCompleted is false
&& !IsCancelled)
{
downloadedPiece.WaitOne(100);
_downloadedPiece.WaitOne(50);
}
}
public override void Close()
{
IsCancelled = true;
while (downloadEnded is not null && !downloadEnded.WaitOne(100)) ;
_cancellationSource.Cancel();
_backgroundDownloadTask?.Wait();
_readFile.Close();
_writeFile.Close();
_networkStream?.Close();
Update();
}
#endregion
~NetworkFileStream()
{
downloadEnded?.Close();
downloadedPiece?.Close();
_downloadedPiece?.Close();
}
}
}

View File

@@ -16,7 +16,7 @@ namespace AaxDecrypter
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
Serilog.Log.Information("Begin downloading unencrypted audiobook.");
//Step 1
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
@@ -39,6 +39,19 @@ namespace AaxDecrypter
}
//Step 3
if (DownloadOptions.DownloadClipsBookmarks)
{
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
if (await Task.Run(Step_DownloadClipsBookmarks))
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
else
{
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
return false;
}
}
//Step 4
Serilog.Log.Information("Begin Step 3: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 3: Cleanup");
@@ -58,7 +71,6 @@ namespace AaxDecrypter
}
}
public override Task CancelAsync()
{
IsCanceled = true;

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>8.6.0.1</Version>
<Version>8.8.2.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="4.0.1" />
<PackageReference Include="Octokit" Version="4.0.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />

View File

@@ -5,7 +5,7 @@ using System.Linq;
using System.Reflection;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core.Collections.Generic;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
@@ -29,6 +29,9 @@ namespace AppScaffolding
public static class LibationScaffolding
{
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static VarietyType Variety
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
@@ -81,7 +84,6 @@ namespace AppScaffolding
//
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_from_7_10_1(config);
}
public static void PopulateMissingConfigValues(Configuration config)
@@ -174,9 +176,18 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
if (!config.Exists(nameof(config.DownloadClipsBookmarks)))
config.DownloadClipsBookmarks = false;
if (!config.Exists(nameof(config.ClipsBookmarksFileFormat)))
config.ClipsBookmarksFileFormat = Configuration.ClipBookmarkFormat.CSV;
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
config.AutoDownloadEpisodes = false;
if (!config.Exists(nameof(config.DownloadSpeedLimit)))
config.DownloadSpeedLimit = 0;
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
@@ -227,7 +238,7 @@ namespace AppScaffolding
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetObject("Serilog", serilogObj);
config.SetNonString(serilogObj, "Serilog");
}
// to restore original: Console.SetOut(origOut);
@@ -370,7 +381,7 @@ namespace AppScaffolding
zipUrl
});
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
}
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{
@@ -457,74 +468,5 @@ namespace AppScaffolding
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
public static void migrate_from_7_10_1(Configuration config)
{
var lastMigrationThrew = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
if (lastMigrationThrew) return;
try
{
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
//and those with improperly identified or missing series. This does not solve cases
//where individual episodes are in the db with a valid series link, but said series'
//parents have not been imported into the database. For those cases, Libation will
//attempt fixup by retrieving parents from the catalog endpoint
using var context = DbContexts.GetContext();
//This migration removes books and series with SERIES_ prefix that were created
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
string removeHackSeries = "delete " +
"from series " +
"where AudibleSeriesId like 'SERIES%'";
string removeHackBooks = "delete " +
"from books " +
"where AudibleProductId like 'SERIES%'";
//Detect series parents that were added to the database as books with ContentType.Episode,
//and change them to ContentType.Parent
string updateContentType =
"UPDATE books " +
"SET contenttype = 4 " +
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
"FROM books " +
"INNER JOIN series " +
"ON ( books.audibleproductid = " +
"series.audibleseriesid) " +
"WHERE books.contenttype = 2)";
//Then detect series parents that were added to the database as books with ContentType.Parent
//but are missing a series link, and add the link (don't know how this happened)
string addMissingSeriesLink =
"INSERT INTO seriesbook " +
"SELECT series.seriesid, " +
"books.bookid, " +
"'- 1' " +
"FROM books " +
"LEFT OUTER JOIN seriesbook " +
"ON books.bookid = seriesbook.bookid " +
"INNER JOIN series " +
"ON books.audibleproductid = series.audibleseriesid " +
"WHERE books.contenttype = 4 " +
"AND seriesbook.seriesid IS NULL";
context.Database.ExecuteSqlRaw(removeHackSeries);
context.Database.ExecuteSqlRaw(removeHackBooks);
context.Database.ExecuteSqlRaw(updateContentType);
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
LibraryCommands.SaveContext(context);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
}
}
}
}

View File

@@ -1,6 +1,34 @@
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
{
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
public record UpgradeProperties
{
private static readonly Regex linkstripper = new Regex(@"\[(.*)\]\(.*\)");
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
public Version LatestRelease { get; }
public string Notes { get; }
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
{
ZipName = zipName;
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
Notes = stripMarkdownLinks(notes);
}
private string stripMarkdownLinks(string body)
{
body = body.Replace(@"\", "");
var matches = linkstripper.Matches(body);
foreach (Match match in matches)
body = body.Replace(match.Groups[0].Value, match.Groups[1].Value);
return body;
}
}
}

View File

@@ -9,226 +9,209 @@ using Dinah.Core;
using DtoImporterService;
using LibationFileManager;
using Serilog;
using static System.Reflection.Metadata.BlobBuilder;
using static DtoImporterService.PerfLogger;
namespace ApplicationServices
{
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler ScanEnd;
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
static LibraryCommands()
{
ScanBegin += (_, __) => Scanning = true;
ScanEnd += (_, __) => Scanning = false;
}
static LibraryCommands()
{
ScanBegin += (_, __) => Scanning = true;
ScanEnd += (_, __) => Scanning = false;
}
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
lock (_lock)
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
return new List<LibraryBook>();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// 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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
lock (_lock)
// 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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
lock (_lock)
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
if (totalCount == 0)
return default;
if (totalCount == 0)
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
Log.Logger.Information("Begin scan for orphaned episode parents");
var newParents = await findAndAddMissingParents(accounts);
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
if (newParents >= 0)
{
//If any episodes are still orphaned, their series have been
//removed from the catalog and we'll never be able to find them.
// 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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
//only do this if findAndAddMissingParents returned >= 0. If it
//returned < 0, an error happened and there's still a chance that
//a future successful run will find missing parents.
removedOrphanedEpisodes();
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
}
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
// 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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
});
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
}
logTime($"pre scanAccountAsync {account.AccountName}");
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
});
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
private static async Task<int> importIntoDbAsync(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()");
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
// this is any changes at all to the database, not just new books
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
@@ -236,181 +219,152 @@ namespace ApplicationServices
return newCount;
}
static void removedOrphanedEpisodes()
{
using var context = DbContexts.GetContext();
try
{
var orphanedEpisodes =
context
.GetLibrary_Flat_NoTracking(includeParents: true)
.FindOrphanedEpisodes();
public static int SaveContext(LibationContext context)
{
try
{
return context.SaveChanges();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
context.LibraryBooks.RemoveRange(orphanedEpisodes);
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
}
}
static async Task<int> findAndAddMissingParents(Account[] accounts)
{
using var context = DbContexts.GetContext();
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
try
{
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
if (!orphanedEpisodes.Any())
return -1;
var orphanedSeries =
orphanedEpisodes
.SelectMany(lb => lb.Book.SeriesLink)
.DistinctBy(s => s.Series.AudibleSeriesId)
.ToList();
// The Catalog endpoint does not require authentication.
var api = new ApiUnauthenticated(accounts[0].Locale);
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
List<ImportItem> newParentsImportItems = new();
foreach (var sp in orphanedSeries)
{
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
if (seriesItem.Relationships is null)
continue;
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
seriesItem.Series = new AudibleApi.Common.Series[]
{
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
};
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
}
var newCoutn = new LibraryBookImporter(context)
.Import(newParentsImportItems);
await context.SaveChangesAsync();
return newCoutn;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
return -1;
}
}
public static int SaveContext(LibationContext context)
{
try
{
return context.SaveChanges();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
if (ex.InnerException is null)
throw new Exception($"{msg}{format(ex)}");
throw new Exception(
$"{msg}{format(ex)}",
new Exception($"Inner Exception{format(ex.InnerException)}"));
}
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
if (ex.InnerException is null)
throw new Exception($"{msg}{format(ex)}");
throw new Exception(
$"{msg}{format(ex)}",
new Exception($"Inner Exception{format(ex.InnerException)}"));
}
}
#endregion
#region remove books
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
{
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
#region remove/restore books
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
private static int removeBooks(List<string> idsToRemove)
{
try
{
if (idsToRemove is null || !idsToRemove.Any())
return 0;
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
context.LibraryBooks.RemoveRange(removeLibraryBooks);
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
return removeLibraryBooks;
}
#endregion
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error removing books");
throw;
}
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
using var context = DbContexts.GetContext();
#region Update book details
public static int UpdateUserDefinedItem(
this Book book,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null)
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus);
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
public static int UpdateUserDefinedItem(
this IEnumerable<Book> books,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null)
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
this Book book,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
this IEnumerable<Book> books,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> updateUserDefinedItem(
books,
books,
udi => {
// blank tags are expected. null tags are not
if (tags is not null && udi.Tags != tags)
if (tags is not null)
udi.Tags = tags;
if (bookStatus is not null && udi.BookStatus != bookStatus.Value)
if (bookStatus.HasValue)
udi.BookStatus = bookStatus.Value;
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
if (pdfStatus is not null && udi.PdfStatus != pdfStatus.Value)
udi.PdfStatus = pdfStatus.Value;
// method handles null logic
udi.SetPdfStatus(pdfStatus);
if (rating is not null)
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
=> book.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
=> books.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdateTags(this Book book, string tags)
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
@@ -422,9 +376,9 @@ namespace ApplicationServices
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.Book.updateUserDefinedItem(action);
=> libraryBook.Book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
@@ -437,14 +391,17 @@ namespace ApplicationServices
if (books is null || !books.Any())
return 0;
foreach (var book in books)
foreach (var book in books)
action?.Invoke(book.UserDefinedItem);
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in books)
{
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
@@ -462,49 +419,49 @@ 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
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
}
}
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
}
}
}

View File

@@ -0,0 +1,198 @@
using AudibleApi.Common;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices
{
public static class RecordExporter
{
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string>
{
nameof(Type.Name),
nameof(IRecord.Created),
nameof(IRecord.Start) + "_ms",
};
if (records.OfType<IAnnotation>().Any())
{
columns.Add(nameof(IAnnotation.AnnotationId));
columns.Add(nameof(IAnnotation.LastModified));
}
if (records.OfType<IRangeAnnotation>().Any())
{
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
columns.Add(nameof(IRangeAnnotation.Text));
}
if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title));
var col = 0;
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
cell.SetCellValue(c);
cell.CellStyle = detailSubtotalCellStyle;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
// Add data rows
foreach (var record in records)
{
col = 0;
row = sheet.CreateRow(++rowIndex);
row.CreateCell(col++).SetCellValue(record.GetType().Name);
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
if (record is IAnnotation annotation)
{
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++);
lastModifiedCell.CellStyle = dateStyle;
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
if (annotation is IRangeAnnotation rangeAnnotation)
{
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
if (rangeAnnotation is Clip clip)
row.CreateCell(col++).SetCellValue(clip.Title);
}
}
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
}
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
var recordsEx = extendRecords(records);
var recordsObj = new JObject
{
{ "title", libraryBook.Book.Title},
{ "asin", libraryBook.Book.AudibleProductId},
{ "exportTime", DateTime.Now},
{ "records", JArray.FromObject(recordsEx) }
};
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
}
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
//Write headers for the present record type that has the most properties
if (records.OfType<Clip>().Any())
csv.WriteHeader(typeof(ClipEx));
else if (records.OfType<Note>().Any())
csv.WriteHeader(typeof(NoteEx));
else if (records.OfType<Bookmark>().Any())
csv.WriteHeader(typeof(BookmarkEx));
else
csv.WriteHeader(typeof(LastHeardEx));
var recordsEx = extendRecords(records);
csv.NextRecord();
csv.WriteRecords(recordsEx.OfType<ClipEx>());
csv.WriteRecords(recordsEx.OfType<NoteEx>());
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
}
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
=> records
.Select<IRecord, IRecordEx>(
r => r switch
{
Clip c => new ClipEx(nameof(Clip), c),
Note n => new NoteEx(nameof(Note), n),
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
_ => throw new InvalidOperationException(),
});
private interface IRecordEx { string Type { get; } }
private record LastHeardEx : LastHeard, IRecordEx
{
public string Type { get; }
public LastHeardEx(string type, LastHeard original) : base(original)
{
Type = type;
}
}
private record BookmarkEx : Bookmark, IRecordEx
{
public string Type { get; }
public BookmarkEx(string type, Bookmark original) : base(original)
{
Type = type;
}
}
private record NoteEx : Note, IRecordEx
{
public string Type { get; }
public NoteEx(string type, Note original) : base(original)
{
Type = type;
}
}
private record ClipEx : Clip, IRecordEx
{
public string Type { get; }
public ClipEx(string type, Clip original) : base(original)
{
Type = type;
}
}
}
}

View File

@@ -43,23 +43,18 @@ namespace ApplicationServices
else
{
foreach (var book in books)
{
UpdateLiberatedStatus(book);
UpdateBookTags(book);
}
UpdateUserDefinedItems(book);
}
}
public static void FullReIndex() => performSafeCommand(e =>
fullReIndex(e)
);
public static void FullReIndex() => performSafeCommand(fullReIndex);
internal static void UpdateLiberatedStatus(Book book) => performSafeCommand(e =>
e.UpdateLiberatedStatus(book)
);
internal static void UpdateBookTags(Book book) => performSafeCommand(e =>
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
{
e.UpdateLiberatedStatus(book);
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
e.UpdateUserRatings(book);
}
);
private static void performSafeCommand(Action<SearchEngine> action)
@@ -87,7 +82,6 @@ namespace ApplicationServices
isUpdating = true;
action(new SearchEngine());
if (!prevIsUpdating)
SearchEngineUpdated?.Invoke(null, null);
}

View File

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

View File

@@ -9,6 +9,9 @@ namespace DataLayer.Configurations
{
entity.HasKey(c => c.CategoryId);
entity.HasIndex(c => c.AudibleCategoryId);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Category.GetEmpty());
}
}
}

View File

@@ -17,6 +17,9 @@ namespace DataLayer.Configurations
.Metadata
.FindNavigation(nameof(Contributor.BooksLink))
.SetPropertyAccessMode(PropertyAccessMode.Field);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Contributor.GetEmpty());
}
}
}

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.0.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -11,6 +11,8 @@ namespace DataLayer
public DateTime DateAdded { get; private set; }
public string Account { get; private set; }
public bool IsDeleted { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{

View File

@@ -146,10 +146,19 @@ namespace DataLayer
}
}
}
public void SetPdfStatus(LiberatedStatus? pdfStatus)
{
// don't change whether pdf is actually available. if null, leave as null. if not null, only assign non-null
// null => non-null : only when adding a supplement
if (pdfStatus.HasValue && PdfStatus.HasValue)
PdfStatus = pdfStatus;
}
public LiberatedStatus? PdfStatus
{
get => _pdfStatus;
set
internal set
{
if (_pdfStatus != value)
{

View File

@@ -47,15 +47,6 @@ namespace DataLayer
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
modelBuilder
.Entity<Category>()
.HasData(Category.GetEmpty());
modelBuilder
.Entity<Contributor>()
.HasData(Contributor.GetEmpty());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
}

View File

@@ -0,0 +1,401 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20221214205106_LibraryBookIsDeleted")]
partial class LibraryBookIsDeleted
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class LibraryBookIsDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "LibraryBooks");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -160,6 +160,9 @@ namespace DataLayer.Migrations
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");

View File

@@ -37,6 +37,20 @@ namespace DataLayer
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
=> library
.Where(lb => !lb.IsDeleted)
.getLibrary();
public static List<LibraryBook> GetDeletedLibraryBooks(this LibationContext context)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.Where(lb => lb.IsDeleted)
.getLibrary()
.ToList();
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
private static IQueryable<LibraryBook> getLibrary(this IQueryable<LibraryBook> library)
=> library
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
@@ -44,7 +58,7 @@ namespace DataLayer
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks

View File

@@ -104,7 +104,7 @@ namespace FileLiberator
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
@@ -133,9 +133,7 @@ namespace FileLiberator
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
return success;
return await abDownloader.RunAsync();
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
@@ -168,6 +166,8 @@ namespace FileLiberator
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet,
DownloadClipsBookmarks = config.DownloadClipsBookmarks,
DownloadSpeedBps = config.DownloadSpeedLimit,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
FixupFile = config.AllowLibationFixup

View File

@@ -4,43 +4,88 @@ using Dinah.Core;
using DataLayer;
using LibationFileManager;
using FileManager;
using System.Threading.Tasks;
using System;
using System.IO;
using ApplicationServices;
namespace FileLiberator
{
public class DownloadOptions : IDownloadOptions
{
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public bool FixupFile { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public bool Downsample { get; init; }
public bool MatchSourceBitrate { get; init; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public class DownloadOptions : IDownloadOptions, IDisposable
{
public event EventHandler<long> DownloadSpeedChanged;
public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public bool DownloadClipsBookmarks { get; init; }
public long DownloadSpeedBps { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public bool FixupFile { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public bool Downsample { get; init; }
public bool MatchSourceBitrate { get; init; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
{
LibraryBookDto = ArgumentValidator
.EnsureNotNull(libraryBook, nameof(libraryBook))
.ToDto();
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
public async Task<string> SaveClipsAndBookmarks(string fileName)
{
if (DownloadClipsBookmarks)
{
var format = Configuration.Instance.ClipsBookmarksFileFormat;
// no null/empty check for key/iv. unencrypted files do not have them
}
}
var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension);
var api = await LibraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
switch(format)
{
case Configuration.ClipBookmarkFormat.CSV:
RecordExporter.ToCsv(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Xlsx:
RecordExporter.ToXlsx(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Json:
RecordExporter.ToJson(filePath, LibraryBook, records);
break;
}
return filePath;
}
return string.Empty;
}
private readonly IDisposable cancellation;
public void Dispose() => cancellation?.Dispose();
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto();
cancellation =
Configuration.Instance
.ObservePropertyChanged<long>(
nameof(Configuration.DownloadSpeedLimit),
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
}
}
}

View File

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

View File

@@ -44,12 +44,20 @@ namespace FileManager
var fileNamePart = pathParts[^1];
pathParts.Remove(fileNamePart);
var fileExtension = Path.GetExtension(fileNamePart);
fileNamePart = fileNamePart[..^fileExtension.Length];
LongPath directory = Path.Join(pathParts.Select(p => replaceFileName(p, paramReplacements, LongPath.MaxFilenameLength)).ToArray());
//If file already exists, GetValidFilename will append " (n)" to the filename.
//This could cause the filename length to exceed MaxFilenameLength, so reduce
//allowable filename length by 5 chars, allowing for up to 99 duplicates.
return FileUtility.GetValidFilename(Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - 5)), replacements, returnFirstExisting);
return FileUtility
.GetValidFilename(
Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension,
replacements,
returnFirstExisting
);
}
private static string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
@@ -88,7 +96,7 @@ namespace FileManager
//Remove 1 character from the end of the longest filename part until
//the total filename is less than max filename length
while (filenameParts.Sum(p => p.Length) > maxFilenameLength)
while (filenameParts.Sum(p => LongPath.GetFilesystemStringLength(p)) > maxFilenameLength)
{
int maxLength = filenameParts.Max(p => p.Length);
var maxEntry = filenameParts.First(p => p.Length == maxLength);
@@ -105,7 +113,7 @@ namespace FileManager
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
return replacements.ReplaceInvalidFilenameChars(value.ToString());
return replacements.ReplaceFilenameChars(value.ToString());
}
}
}

View File

@@ -6,11 +6,14 @@ using System.Text.RegularExpressions;
using Dinah.Core;
using Polly;
using Polly.Retry;
using Dinah.Core.Collections.Generic;
namespace FileManager
{
public static class FileUtility
{
/// <summary>
/// "txt" => ".txt"
/// <br />".txt" => ".txt"
@@ -55,15 +58,15 @@ namespace FileManager
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir?.Truncate(LongPath.MaxDirectoryLength) ?? string.Empty;
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var extension = Path.GetExtension(path);
var filename = Path.GetFileNameWithoutExtension(path).Truncate(LongPath.MaxFilenameLength - extension.Length);
var filename = Path.GetFileNameWithoutExtension(path).TruncateFilename(LongPath.MaxFilenameLength - extension.Length);
var fileStem = Path.Combine(dir, filename);
var fullfilename = fileStem.Truncate(LongPath.MaxPathLength - extension.Length) + extension;
var fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - extension.Length) + extension;
fullfilename = removeInvalidWhitespace(fullfilename);
@@ -71,7 +74,7 @@ namespace FileManager
while (File.Exists(fullfilename) && !returnFirstExisting)
{
var increm = $" ({++i})";
fullfilename = fileStem.Truncate(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - extension.Length) + increm + extension;
}
return fullfilename;
@@ -84,7 +87,7 @@ namespace FileManager
var pathNoPrefix = path.PathWithoutPrefix;
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix);
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
return pathNoPrefix;
@@ -129,6 +132,18 @@ namespace FileManager
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
public static string TruncateFilename(this string filenameStr, int limit)
{
if (LongPath.IsWindows) return filenameStr.Truncate(limit);
int index = filenameStr.Length;
while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit)
index--;
return filenameStr[..index];
}
/// <summary>
/// Move file.
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length

View File

@@ -10,22 +10,59 @@ namespace FileManager
{
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
public const int MaxDirectoryLength = MaxPathLength - 13;
public const int MaxPathLength = short.MaxValue;
public const int MaxFilenameLength = 255;
private const int MAX_PATH = 260;
private const string LONG_PATH_PREFIX = "\\\\?\\";
public string Path { get; init; }
public override string ToString() => Path;
private static readonly PlatformID PlatformID = Environment.OSVersion.Platform;
public static readonly int MaxDirectoryLength;
public static readonly int MaxPathLength;
private const int WIN_MAX_PATH = 260;
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public string Path { get; }
static LongPath()
{
if (IsWindows)
{
MaxPathLength = short.MaxValue;
MaxDirectoryLength = MaxPathLength - 13;
}
else if (IsOSX)
{
MaxPathLength = 1024;
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
}
else
{
MaxPathLength = 4096;
MaxDirectoryLength = MaxPathLength - MaxFilenameLength;
}
}
private LongPath(string path)
{
if (IsWindows && path.Length > MaxPathLength)
throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} character limit. ({path})");
if (!IsWindows && Encoding.UTF8.GetByteCount(path) > MaxPathLength)
throw new System.IO.PathTooLongException($"Path exceeds {MaxPathLength} byte limit. ({path})");
Path = path;
}
//Filename limits on NTFS and FAT filesystems are based on characters,
//but on ext* filesystems they're based on bytes. The ext* filesystems
//don't care about encoding, so how unicode characters are encoded is
///a choice made by the linux kernel. As best as I can tell, pretty
//much everyone uses UTF-8.
public static int GetFilesystemStringLength(StringBuilder filename)
=> LongPath.IsWindows ?
filename.Length
: Encoding.UTF8.GetByteCount(filename.ToString());
public static implicit operator LongPath(string path)
{
if (PlatformID is PlatformID.Unix) return new LongPath { Path = path };
if (!IsWindows) return new LongPath(path);
if (path is null) return null;
@@ -33,15 +70,15 @@ namespace FileManager
//the name to an NT-style name, except when using the "\\?\" prefix
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
if (path.StartsWith(LONG_PATH_PREFIX))
return new LongPath { Path = path };
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
return new LongPath { Path = LONG_PATH_PREFIX + path };
else if (path.StartsWith("\\\\"))
if (path.StartsWith(WIN_LONG_PATH_PREFIX))
return new LongPath(path);
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith(@"UNC\"))
return new LongPath(WIN_LONG_PATH_PREFIX + path);
else if (path.StartsWith(@"\\"))
//The "\\?\" prefix can also be used with paths constructed according to the
//universal naming convention (UNC). To specify such a path using UNC, use
//the "\\?\UNC\" prefix.
return new LongPath { Path = LONG_PATH_PREFIX + "UNC\\" + path.Substring(2) };
return new LongPath(WIN_LONG_PATH_PREFIX + @"UNC\" + path.Substring(2));
else
{
//These prefixes are not used as part of the path itself. They indicate that
@@ -50,9 +87,9 @@ namespace FileManager
//a period to represent the current directory, or double dots to represent the
//parent directory. Because you cannot use the "\\?\" prefix with a relative
//path, relative paths are always limited to a total of MAX_PATH characters.
if (path.Length > MAX_PATH)
if (path.Length > WIN_MAX_PATH)
throw new System.IO.PathTooLongException();
return new LongPath { Path = path };
return new LongPath(path);
}
}
@@ -63,7 +100,7 @@ namespace FileManager
{
get
{
if (PlatformID is PlatformID.Unix) return Path;
if (!IsWindows) return Path;
//Short Path names are useful for navigating to the file in windows explorer,
//which will not recognize paths longer than MAX_PATH. Short path names are not
@@ -103,7 +140,7 @@ namespace FileManager
{
get
{
if (PlatformID is PlatformID.Unix) return Path;
if (!IsWindows) return Path;
if (Path is null) return null;
StringBuilder longPathBuffer = new(MaxPathLength);
@@ -117,13 +154,16 @@ namespace FileManager
{
get
{
if (PlatformID is PlatformID.Unix) return Path;
if (!IsWindows) return Path;
return
Path?.StartsWith(LONG_PATH_PREFIX) == true ? Path.Remove(0, LONG_PATH_PREFIX.Length)
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
:Path;
}
}
public override string ToString() => Path;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);

View File

@@ -49,10 +49,22 @@ namespace FileManager
public T GetNonString<T>(string propertyName)
{
var obj = GetObject(propertyName);
if (obj is null) return default;
if (obj is JValue jValue) return jValue.Value<T>();
if (obj is JObject jObject) return jObject.ToObject<T>();
return (T)obj;
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (jValue.Type == JTokenType.String && typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
? (T)enumVal
: Enum.GetValues(typeof(T)).Cast<T>().First();
}
return jValue.Value<T>();
}
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
public object GetObject(string propertyName)
@@ -179,6 +191,7 @@ namespace FileManager
}
catch (Exception exDebug)
{
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
return false;
}

View File

@@ -7,15 +7,15 @@ using System.Linq;
namespace FileManager
{
public class Replacement : ICloneable
public record Replacement
{
public const int FIXED_COUNT = 6;
internal const char QUOTE_MARK = '"';
[JsonIgnore] public bool Mandatory { get; internal set; }
[JsonIgnore] public bool Mandatory { get; set; }
[JsonProperty] public char CharacterToReplace { get; private set; }
[JsonProperty] public string ReplacementString { get; set; }
[JsonProperty] public string Description { get; private set; }
[JsonProperty] public string ReplacementString { get; private set; }
[JsonProperty] public string Description { get; set; }
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
public Replacement(char charToReplace, string replacementString, string description)
@@ -30,8 +30,6 @@ namespace FileManager
Mandatory = mandatory;
}
public object Clone() => new Replacement(CharacterToReplace, ReplacementString, Description, Mandatory);
public void Update(char charToReplace, string replacementString, string description)
{
ReplacementString = replacementString;
@@ -61,59 +59,118 @@ namespace FileManager
[JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters
{
public static readonly ReplacementCharacters Default = new()
public override bool Equals(object obj)
{
Replacements = new List<Replacement>()
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash(""),
Replacement.OpenQuote("“"),
Replacement.CloseQuote("”"),
Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""),
Replacement.Colon(""),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),
}
};
for (int i = 0; i < Replacements.Count; i++)
if (Replacements[i] != second.Replacements[i])
return false;
public static readonly ReplacementCharacters LoFiDefault = new()
{
Replacements = new List<Replacement>()
return true;
}
return false;
}
public override int GetHashCode() => Replacements.GetHashCode();
public static readonly ReplacementCharacters Default
= IsWindows
? new()
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("'"),
Replacement.CloseQuote("'"),
Replacement.OtherQuote("'"),
Replacement.OpenAngleBracket("{"),
Replacement.CloseAngleBracket("}"),
Replacement.Colon("-"),
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash(""),
Replacement.OpenQuote(""),
Replacement.CloseQuote(""),
Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""),
Replacement.Colon(""),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),
}
}
};
public static readonly ReplacementCharacters Barebones = new()
{
Replacements = new List<Replacement>()
: new()
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("_"),
Replacement.CloseQuote("_"),
Replacement.OtherQuote("_"),
}
};
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote(""),
Replacement.CloseQuote("”"),
Replacement.OtherQuote("\"")
}
};
private static readonly char[] invalidChars = Path.GetInvalidPathChars().Union(new[] {
'*', '?', ':',
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
// In live code, Path.GetInvalidPathChars() does not include them
'"', '<', '>'
public static readonly ReplacementCharacters LoFiDefault
= IsWindows
? new()
{
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("'"),
Replacement.CloseQuote("'"),
Replacement.OtherQuote("'"),
Replacement.OpenAngleBracket("{"),
Replacement.CloseAngleBracket("}"),
Replacement.Colon("-"),
}
}
: new ()
{
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote("\""),
Replacement.CloseQuote("\""),
Replacement.OtherQuote("\"")
}
};
public static readonly ReplacementCharacters Barebones
= IsWindows
? new ()
{
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("_"),
Replacement.CloseQuote("_"),
Replacement.OtherQuote("_")
}
}
: new ()
{
Replacements = new Replacement[]
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote("\""),
Replacement.CloseQuote("\""),
Replacement.OtherQuote("\"")
}
};
private static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] {
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] {
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
public IReadOnlyList<Replacement> Replacements { get; init; }
@@ -158,6 +215,10 @@ namespace FileManager
return OtherQuote;
}
if (!IsWindows && toReplace == BackSlash.CharacterToReplace)
return BackSlash.ReplacementString;
//Replace any other non-mandatory characters
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
{
var r = Replacements[i];
@@ -167,13 +228,12 @@ namespace FileManager
return DefaultReplacement;
}
public static bool ContainsInvalidPathChar(string path)
=> path.Any(c => invalidChars.Contains(c));
=> path.Any(c => invalidPathChars.Contains(c));
public static bool ContainsInvalidFilenameChar(string path)
=> path.Any(c => invalidChars.Concat(new char[] { '\\', '/' }).Contains(c));
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
public string ReplaceInvalidFilenameChars(string fileName)
public string ReplaceFilenameChars(string fileName)
{
if (string.IsNullOrEmpty(fileName)) return string.Empty;
var builder = new System.Text.StringBuilder();
@@ -181,7 +241,9 @@ namespace FileManager
{
var c = fileName[i];
if (invalidChars.Contains(c) || c == ForwardSlash.CharacterToReplace || c == BackSlash.CharacterToReplace)
if (invalidPathChars.Contains(c)
|| invalidSlashes.Contains(c)
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
{
char preceding = i > 0 ? fileName[i - 1] : default;
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
@@ -189,30 +251,42 @@ namespace FileManager
}
else
builder.Append(c);
}
return builder.ToString();
}
public string ReplaceInvalidPathChars(string pathStr)
public string ReplacePathChars(string pathStr)
{
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
// replace all colons except within the first 2 chars
var builder = new System.Text.StringBuilder();
for (var i = 0; i < pathStr.Length; i++)
{
var c = pathStr[i];
if (!invalidChars.Contains(c) || (c == ':' && i == 1 && Path.IsPathRooted(pathStr)))
builder.Append(c);
else
if (
(
invalidPathChars.Contains(c)
|| ( // Replace any other legal characters that they user wants.
c != Path.DirectorySeparatorChar
&& c != Path.AltDirectorySeparatorChar
&& Replacements.Any(r => r.CharacterToReplace == c)
)
)
&& !( // replace all colons except drive letter designator on Windows
c == ':'
&& i == 1
&& Path.IsPathRooted(pathStr)
&& IsWindows
)
)
{
char preceding = i > 0 ? pathStr[i - 1] : default;
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
char preceding = i > 0 ? pathStr[i - 1] : default;
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
}
else
builder.Append(c);
}
return builder.ToString();
}
@@ -234,28 +308,19 @@ namespace FileManager
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default.
var default0 = Replacement.OtherInvalid("");
var default1 = Replacement.FilenameForwardSlash("");
var default2 = Replacement.FilenameBackSlash("");
var default3 = Replacement.OpenQuote("");
var default4 = Replacement.CloseQuote("");
var default5 = Replacement.OtherQuote("");
if (dict.Count < Replacement.FIXED_COUNT ||
dict[0].CharacterToReplace != default0.CharacterToReplace || dict[0].Description != default0.Description ||
dict[1].CharacterToReplace != default1.CharacterToReplace || dict[1].Description != default1.Description ||
dict[2].CharacterToReplace != default2.CharacterToReplace || dict[2].Description != default2.Description ||
dict[3].CharacterToReplace != default3.CharacterToReplace || dict[3].Description != default3.Description ||
dict[4].CharacterToReplace != default4.CharacterToReplace || dict[4].Description != default4.Description ||
dict[5].CharacterToReplace != default5.CharacterToReplace || dict[5].Description != default5.Description ||
dict.Any(r => ReplacementCharacters.ContainsInvalidPathChar(r.ReplacementString))
)
{
dict = ReplacementCharacters.Default.Replacements;
}
//First FIXED_COUNT are mandatory
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
{
if (dict.Count < Replacement.FIXED_COUNT
|| dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace
|| dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description)
{
dict = ReplacementCharacters.Default.Replacements;
break;
}
//First FIXED_COUNT are mandatory
dict[i].Mandatory = true;
}
return new ReplacementCharacters { Replacements = dict };
}
@@ -265,7 +330,7 @@ namespace FileManager
ReplacementCharacters replacements = (ReplacementCharacters)value;
var propertyNames = replacements.Replacements
.Select(c => JObject.FromObject(c)).ToList();
.Select(JObject.FromObject).ToList();
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));

View File

@@ -17,10 +17,12 @@ namespace HangoverAvalonia
{
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
desktop.MainWindow = new MainWindow
var mainWindow = new MainWindow
{
DataContext = new MainWindowViewModel(),
DataContext = new MainVM(),
};
desktop.MainWindow = mainWindow;
mainWindow.OnLoad();
}
base.OnFrameworkInitializationCompleted();

View File

@@ -0,0 +1,30 @@
<UserControl 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="800" d:DesignHeight="450"
x:Class="HangoverAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True"
VerticalCacheLength="1.2"
HorizontalCacheLength="1"
Items="{Binding CheckboxItems}"
ItemTemplate="{StaticResource elementFactory}" />
</ScrollViewer>
</UserControl>

View File

@@ -0,0 +1,104 @@
using Avalonia;
using Avalonia.Collections;
using Avalonia.Controls;
using HangoverAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace HangoverAvalonia.Controls
{
public partial class CheckedListBox : UserControl
{
public event EventHandler<ItemCheckEventArgs> ItemCheck;
public static readonly StyledProperty<IEnumerable> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public IEnumerable<object> CheckedItems =>
_viewModel
.CheckboxItems
.Where(i => i.IsChecked)
.Select(i => i.Item);
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
public void SetItemChecked(object item, bool isChecked)
{
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
if (obj is not null)
obj.IsChecked = isChecked;
}
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
}
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
{
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
ItemCheck?.Invoke(this, args);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.SetItems(Items);
base.OnPropertyChanged(change);
}
public class CheckedListBoxViewModel : ViewModelBase
{
public event EventHandler<CheckBoxViewModel> CheckedChanged;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
public void SetItems(IEnumerable items)
{
UnsubscribeFromItems(CheckboxItems);
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
SubscribeToItems(CheckboxItems);
this.RaisePropertyChanged(nameof(CheckboxItems));
}
private void SubscribeToItems(IEnumerable objects)
{
foreach (var i in objects.OfType<INotifyPropertyChanged>())
i.PropertyChanged += I_PropertyChanged;
}
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
{
if (objects is null) return;
foreach (var i in objects)
i.PropertyChanged -= I_PropertyChanged;
}
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
}
}
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 class ItemCheckEventArgs : EventArgs
{
public int ItemIndex { get; init; }
public bool IsChecked { get; init; }
public object Item { get; init; }
}
}

View File

@@ -50,7 +50,15 @@
<None Remove="Assets\hangover.ico" />
<None Remove="hangover.ico" />
</ItemGroup>
<ItemGroup>
<Compile Update="ViewModels\MainVM.*.cs">
<DependentUpon>MainVM.cs</DependentUpon>
</Compile>
<Compile Update="Views\MainWindow.*.cs">
<DependentUpon>MainWindow.axaml</DependentUpon>
</Compile>
</ItemGroup>
<ItemGroup>
<AvaloniaResource Include="hangover.ico" />
@@ -63,12 +71,14 @@
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="0.10.18" />
<PackageReference Include="Avalonia.Desktop" Version="0.10.18" />
<PackageReference Include="Avalonia" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview4" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.18" />
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.18" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.4.2" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview4" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview4" />
<PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -1,5 +1,4 @@
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
using System;

View File

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

View File

@@ -0,0 +1,41 @@
using ApplicationServices;
using DataLayer;
using ReactiveUI;
using System.Collections.Generic;
namespace HangoverAvalonia.ViewModels
{
public partial class MainVM
{
private List<LibraryBook> _deletedBooks;
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private int _totalBooksCount = 0;
private int _checkedBooksCount = 0;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
}
private void Load_deletedVM()
{
reload();
}
public void reload()
{
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
_checkedBooksCount = 0;
_totalBooksCount = DeletedBooks.Count;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
}

View File

@@ -0,0 +1,11 @@
namespace HangoverAvalonia.ViewModels
{
public partial class MainVM : ViewModelBase
{
public MainVM()
{
Load_databaseVM();
Load_deletedVM();
}
}
}

View File

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

View File

@@ -1,7 +1,4 @@
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Text;
namespace HangoverAvalonia.ViewModels
{

View File

@@ -0,0 +1,11 @@
namespace HangoverAvalonia.Views
{
public partial class MainWindow
{
private void cliTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
}
}

View File

@@ -0,0 +1,16 @@
namespace HangoverAvalonia.Views
{
public partial class MainWindow
{
private void databaseTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_viewModel.ExecuteQuery();
}
}
}

View File

@@ -0,0 +1,40 @@
using ApplicationServices;
using DataLayer;
using HangoverAvalonia.Controls;
using System.Linq;
namespace HangoverAvalonia.Views
{
public partial class MainWindow
{
private void deletedTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
if (_viewModel.DeletedBooks.Count == 0)
_viewModel.reload();
}
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
{
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
}
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, true);
}
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, false);
}
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
if (qtyChanges > 0)
_viewModel.reload();
}
}
}

View File

@@ -3,33 +3,36 @@
xmlns:vm="using:HangoverAvalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
Width="800" Height="500"
x:Class="HangoverAvalonia.Views.MainWindow"
Icon="/Assets/hangover.ico "
Title="Hangover: Libation debug and recovery tool">
<Design.DataContext>
<vm:MainWindowViewModel/>
<vm:MainVM/>
</Design.DataContext>
<TabControl Grid.Row="0">
<TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="18"/>
<Setter Property="Height" Value="23"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="30"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Padding" Value="8,2,8,0"/>
<Setter Property="MinHeight" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,5"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="20,5,20,5"/>
</Style>
</TabControl.Styles>
<!-- Database Tab -->
<TabItem>
<TabItem Name="databaseTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
</TabItem.Header>
@@ -52,7 +55,6 @@
<Button
Grid.Row="3"
Padding="20,5,20,5"
Content="Execute"
IsEnabled="{Binding DatabaseFound}"
Click="Execute_Click" />
@@ -65,8 +67,45 @@
</Grid>
</TabItem>
<!-- Deleted Books Tab -->
<TabItem Name="deletedTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
</TabItem.Header>
<Grid
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="To restore deleted book, check box and save" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
Name="deletedCbl"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,Auto,*">
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" />
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" />
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" />
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" />
</Grid>
</Grid>
</TabItem>
<!-- Command Line Interface Tab -->
<TabItem>
<TabItem Name="cliTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
</TabItem.Header>

View File

@@ -1,3 +1,4 @@
using AppScaffolding;
using Avalonia.Controls;
using HangoverAvalonia.ViewModels;
@@ -5,15 +6,22 @@ namespace HangoverAvalonia.Views
{
public partial class MainWindow : Window
{
MainWindowViewModel _viewModel => DataContext as MainWindowViewModel;
MainVM _viewModel => DataContext as MainVM;
public MainWindow()
{
InitializeComponent();
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
}
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public void OnLoad()
{
_viewModel.ExecuteQuery();
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
}
}
}

View File

@@ -11,7 +11,7 @@ namespace HangoverWinForms
private void cliTab_VisibleChanged(object sender, EventArgs e)
{
if (!databaseTab.Visible)
if (!cliTab.Visible)
return;
}
}

View File

@@ -0,0 +1,73 @@
using ApplicationServices;
using DataLayer;
namespace HangoverWinForms
{
public partial class Form1
{
private string deletedCheckedTemplate;
private void Load_deletedTab()
{
deletedCheckedTemplate = deletedCheckedLbl.Text;
}
private void deletedTab_VisibleChanged(object sender, EventArgs e)
{
if (!deletedTab.Visible)
return;
if (deletedCbl.Items.Count == 0)
reload();
}
private void deletedCbl_ItemCheck(object sender, ItemCheckEventArgs e)
{
// CheckedItems.Count is not updated until after the event fires
setLabel(e.NewValue);
}
private void checkAllBtn_Click(object sender, EventArgs e)
{
for (var i = 0; i < deletedCbl.Items.Count; i++)
deletedCbl.SetItemChecked(i, true);
}
private void uncheckAllBtn_Click(object sender, EventArgs e)
{
for (var i = 0; i < deletedCbl.Items.Count; i++)
deletedCbl.SetItemChecked(i, false);
}
private void saveBtn_Click(object sender, EventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
if (qtyChanges > 0)
reload();
}
private void reload()
{
deletedCbl.Items.Clear();
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
deletedCbl.Items.Add(lb);
setLabel();
}
private void setLabel(CheckState? checkedState = null)
{
var pre = deletedCbl.CheckedItems.Count;
int count = checkedState switch
{
CheckState.Checked => pre + 1,
CheckState.Unchecked => pre - 1,
_ => pre,
};
deletedCheckedLbl.Text = string.Format(deletedCheckedTemplate, count, deletedCbl.Items.Count);
}
}
}

View File

@@ -36,14 +36,23 @@
this.sqlTb = new System.Windows.Forms.TextBox();
this.sqlLbl = new System.Windows.Forms.Label();
this.databaseFileLbl = new System.Windows.Forms.Label();
this.deletedTab = new System.Windows.Forms.TabPage();
this.deletedCheckedLbl = new System.Windows.Forms.Label();
this.label1 = new System.Windows.Forms.Label();
this.saveBtn = new System.Windows.Forms.Button();
this.uncheckAllBtn = new System.Windows.Forms.Button();
this.checkAllBtn = new System.Windows.Forms.Button();
this.deletedCbl = new System.Windows.Forms.CheckedListBox();
this.cliTab = new System.Windows.Forms.TabPage();
this.tabControl1.SuspendLayout();
this.databaseTab.SuspendLayout();
this.deletedTab.SuspendLayout();
this.SuspendLayout();
//
// tabControl1
//
this.tabControl1.Controls.Add(this.databaseTab);
this.tabControl1.Controls.Add(this.deletedTab);
this.tabControl1.Controls.Add(this.cliTab);
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabControl1.Location = new System.Drawing.Point(0, 0);
@@ -119,6 +128,86 @@
this.databaseFileLbl.TabIndex = 0;
this.databaseFileLbl.Text = "Database file: ";
//
// deletedTab
//
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
this.deletedTab.Controls.Add(this.label1);
this.deletedTab.Controls.Add(this.saveBtn);
this.deletedTab.Controls.Add(this.uncheckAllBtn);
this.deletedTab.Controls.Add(this.checkAllBtn);
this.deletedTab.Controls.Add(this.deletedCbl);
this.deletedTab.Location = new System.Drawing.Point(4, 24);
this.deletedTab.Name = "deletedTab";
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
this.deletedTab.Size = new System.Drawing.Size(792, 422);
this.deletedTab.TabIndex = 2;
this.deletedTab.Text = "Deleted Books";
this.deletedTab.UseVisualStyleBackColor = true;
//
// deletedCheckedLbl
//
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deletedCheckedLbl.AutoSize = true;
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
this.deletedCheckedLbl.TabIndex = 6;
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 3);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(239, 15);
this.label1.TabIndex = 0;
this.label1.Text = "To restore deleted book, check box and save";
//
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(709, 391);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.TabIndex = 5;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
this.uncheckAllBtn.TabIndex = 4;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
this.checkAllBtn.TabIndex = 3;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// deletedCbl
//
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.deletedCbl.FormattingEnabled = true;
this.deletedCbl.Location = new System.Drawing.Point(8, 21);
this.deletedCbl.Name = "deletedCbl";
this.deletedCbl.Size = new System.Drawing.Size(776, 364);
this.deletedCbl.TabIndex = 2;
this.deletedCbl.ItemCheck += new System.Windows.Forms.ItemCheckEventHandler(this.deletedCbl_ItemCheck);
//
// cliTab
//
this.cliTab.Location = new System.Drawing.Point(4, 24);
@@ -140,6 +229,8 @@
this.tabControl1.ResumeLayout(false);
this.databaseTab.ResumeLayout(false);
this.databaseTab.PerformLayout();
this.deletedTab.ResumeLayout(false);
this.deletedTab.PerformLayout();
this.ResumeLayout(false);
}
@@ -154,5 +245,12 @@
private Label sqlLbl;
private Button sqlExecuteBtn;
private TabPage cliTab;
private TabPage deletedTab;
private CheckedListBox deletedCbl;
private Label label1;
private Button saveBtn;
private Button uncheckAllBtn;
private Button checkAllBtn;
private Label deletedCheckedLbl;
}
}

View File

@@ -1,4 +1,6 @@
namespace HangoverWinForms
using AppScaffolding;
namespace HangoverWinForms
{
public partial class Form1 : Form
{
@@ -6,11 +8,17 @@
{
InitializeComponent();
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
cliTab.VisibleChanged += cliTab_VisibleChanged;
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
Load_databaseTab();
Load_cliTab();
Load_deletedTab();
}
}
}

View File

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

View File

@@ -8,9 +8,9 @@
</Application.DataTemplates>
<Application.Styles>
<FluentTheme Mode="Light"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/DefaultTheme.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Default/Accents/BaseLight.xaml"/>
<FluentTheme Mode="Light"/>
<StyleInclude Source="avares://Avalonia.Themes.Fluent/FluentLight.xaml"/>
<StyleInclude Source="avares://Avalonia.Themes.Fluent/Accents/BaseLight.xaml"/>
<StyleInclude Source="/Assets/DataGridTheme.xaml"/>
<StyleInclude Source="/Assets/LibationStyles.xaml"/>
</Application.Styles>

View File

@@ -42,9 +42,6 @@ namespace LibationAvalonia
{
LoadStyles();
var SEGOEUI = new Typeface(new FontFamily(new Uri("avares://Libation/Assets/WINGDING.TTF"), "SEGOEUI_Local"));
var gtf = FontManager.Current.GetOrAddGlyphTypeface(SEGOEUI);
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
if (SetupRequired)

View File

@@ -1,8 +1,8 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<Styles.Resources>
<Color x:Key="SeriesEntryGridBackgroundColor">#FFE6FFE6</Color>
<Color x:Key="SeriesEntryGridBackgroundColor">#cdffcd</Color>
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Color="{StaticResource SeriesEntryGridBackgroundColor}" />
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.5" Color="{StaticResource SeriesEntryGridBackgroundColor}" />
<SolidColorBrush x:Key="ProcessQueueBookFailedBrush" Color="LightCoral" />
<SolidColorBrush x:Key="ProcessQueueBookCompletedBrush" Color="PaleGreen" />
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,4 +1,5 @@
using Avalonia.Media;
using Avalonia.Controls;
using Avalonia.Media;
using Avalonia.Threading;
using System;
using System.Threading;
@@ -16,5 +17,7 @@ namespace LibationAvalonia
return brush;
return defaultBrush;
}
public static Window GetParentWindow(this IControl control) => control.VisualRoot as Window;
}
}

View File

@@ -1,5 +0,0 @@
<DataGridCheckBoxColumn xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
x:Class="LibationAvalonia.Controls.DataGridCheckBoxColumnExt">
</DataGridCheckBoxColumn >

View File

@@ -1,10 +1,11 @@
using Avalonia.Controls;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using System;
using System.Linq;
namespace LibationAvalonia.Controls
{
public partial class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
{
protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem)
{

View File

@@ -0,0 +1,71 @@
using Avalonia.Collections;
using Avalonia.Controls;
using LibationAvalonia.ViewModels;
using System;
using System.Reflection;
namespace LibationAvalonia.Controls
{
internal static class DataGridContextMenus
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs> CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
private static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
static DataGridContextMenus()
{
ContextMenu.Items = MenuItems;
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
}
public static void AttachContextMenu(this DataGridCell cell)
{
if (cell is not null && cell.ContextMenu is null)
{
cell.ContextRequested += Cell_ContextRequested;
cell.ContextMenu = ContextMenu;
}
}
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell && cell.DataContext is GridEntry entry)
{
var args = new DataGridCellContextMenuStripNeededEventArgs
{
Column = OwningColumnProperty.GetValue(cell) as DataGridColumn,
GridEntry = entry,
ContextMenu = ContextMenu
};
args.ContextMenuItems.Clear();
CellContextMenuStripNeeded?.Invoke(sender, args);
e.Handled = args.ContextMenuItems.Count == 0;
}
else
e.Handled = true;
}
}
public class DataGridCellContextMenuStripNeededEventArgs
{
private static readonly MethodInfo GetCellValueMethod;
static DataGridCellContextMenuStripNeededEventArgs()
{
GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance);
}
private static string GetCellValue(DataGridColumn column, object item)
=> GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? "";
public string CellClipboardContents => GetCellValue(Column, GridEntry);
public DataGridColumn Column { get; init; }
public GridEntry GridEntry { get; init; }
public ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.Items as AvaloniaList<Control>;
}
}

View File

@@ -0,0 +1,60 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using DataLayer;
namespace LibationAvalonia.Controls
{
public class DataGridMyRatingColumn : DataGridBoundColumn
{
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{
BindingTarget = MyRatingCellEditor.RatingProperty;
}
protected override IControl GenerateElement(DataGridCell cell, object dataItem)
{
var myRatingElement = new MyRatingCellEditor
{
Name = "CellMyRatingDisplay",
IsEditingMode = false
};
ToolTip.SetTip(myRatingElement, "Click to change ratings");
cell?.AttachContextMenu();
if (Binding != null)
{
myRatingElement.Bind(BindingTarget, Binding);
}
return myRatingElement;
}
protected override IControl GenerateEditingElementDirect(DataGridCell cell, object dataItem)
{
var myRatingElement = new MyRatingCellEditor
{
Name = "CellMyRatingEditor",
IsEditingMode = true
};
return myRatingElement;
}
protected override object PrepareCellForEdit(IControl editingElement, RoutedEventArgs editingEventArgs)
=> editingElement is MyRatingCellEditor myRating
? myRating.Rating
: DefaultRating;
protected override void CancelCellEdit(IControl editingElement, object uneditedValue)
{
if (editingElement is MyRatingCellEditor myRating)
{
var uneditedRating = uneditedValue as Rating;
myRating.Rating = uneditedRating ?? DefaultRating;
}
}
}
}

View File

@@ -0,0 +1,15 @@
using Avalonia.Controls;
using System;
using System.Linq;
namespace LibationAvalonia.Controls
{
public partial class DataGridTemplateColumnExt : DataGridTemplateColumn
{
protected override IControl GenerateElement(DataGridCell cell, object dataItem)
{
cell?.AttachContextMenu();
return base.GenerateElement(cell, dataItem);
}
}
}

View File

@@ -5,6 +5,7 @@ using Dinah.Core;
using LibationFileManager;
using System.Collections.Generic;
using ReactiveUI;
using System.Linq;
namespace LibationAvalonia.Controls
{
@@ -16,7 +17,6 @@ namespace LibationAvalonia.Controls
public static readonly StyledProperty<string> SubDirectoryProperty =
AvaloniaProperty.Register<DirectorySelectControl, string>(nameof(SubDirectory));
public static readonly StyledProperty<string> DirectoryProperty =
AvaloniaProperty.Register<DirectorySelectControl, string>(nameof(Directory));
@@ -90,8 +90,19 @@ namespace LibationAvalonia.Controls
private async void CustomDirBrowseBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
OpenFolderDialog ofd = new();
customStates.CustomDir = await ofd.ShowAsync(VisualRoot as Window);
var options = new Avalonia.Platform.Storage.FolderPickerOpenOptions
{
AllowMultiple = false
};
var selectedFolders = await (VisualRoot as Window).StorageProvider.OpenFolderPickerAsync(options);
customStates.CustomDir =
selectedFolders
.SingleOrDefault()?.
TryGetUri(out var uri) is true
? uri.LocalPath
: customStates.CustomDir;
}
private void CheckStates_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
@@ -125,7 +136,6 @@ namespace LibationAvalonia.Controls
Directory = customStates.CustomChecked ? selectedDir : System.IO.Path.Combine(selectedDir, SubDirectory);
}
private void DirectoryOrCustomSelectControl_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
{
if (e.Property.Name == nameof(Directory) && e.OldValue is null)

View File

@@ -15,15 +15,15 @@ namespace LibationAvalonia.Controls
{
InitializeComponent();
}
protected override void OnPointerEnter(PointerEventArgs e)
protected override void OnPointerEntered(PointerEventArgs e)
{
this.Cursor = HandCursor;
base.OnPointerEnter(e);
base.OnPointerEntered(e);
}
protected override void OnPointerLeave(PointerEventArgs e)
protected override void OnPointerExited(PointerEventArgs e)
{
this.Cursor = Cursor.Default;
base.OnPointerLeave(e);
base.OnPointerExited(e);
}
private void InitializeComponent()

View File

@@ -0,0 +1,54 @@
<UserControl 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="115" d:DesignHeight="80"
x:Class="LibationAvalonia.Controls.MyRatingCellEditor">
<Panel Background="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid Name="ratingsGrid" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="3,0,0,0" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
<Grid.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="11" />
</Style>
<Style Selector="StackPanel > TextBlock">
<Setter Property="Padding" Value="0,0,-2,0" />
</Style>
</Grid.Styles>
<TextBlock Grid.Column="0" Grid.Row="0" Name="tblockOverall" Text="Overall:" />
<TextBlock Grid.Column="0" Grid.Row="1" Name="tblockPerform" Text="Perform:" />
<TextBlock Grid.Column="0" Grid.Row="2" Name="tblockStory" Text="Story:" />
<Panel Background="Transparent" PointerExited="Panel_PointerExited" Grid.Column="1" Grid.Row="0">
<StackPanel Name="panelOverall" Orientation="Horizontal">
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
</StackPanel>
</Panel>
<Panel Background="Transparent" PointerExited="Panel_PointerExited" Grid.Column="1" Grid.Row="1">
<StackPanel Name="panelPerform" Orientation="Horizontal">
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
</StackPanel>
</Panel>
<Panel Background="Transparent" PointerExited="Panel_PointerExited" Grid.Column="1" Grid.Row="2">
<StackPanel Name="panelStory" Orientation="Horizontal">
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
<TextBlock PointerEntered="Star_PointerEntered" Tapped="Star_Tapped" />
</StackPanel>
</Panel>
</Grid>
</Panel>
</UserControl>

View File

@@ -0,0 +1,108 @@
using Avalonia;
using Avalonia.Controls;
using DataLayer;
using System.Linq;
namespace LibationAvalonia.Controls
{
public partial class MyRatingCellEditor : UserControl
{
private const string SOLID_STAR = "★";
private const string HOLLOW_STAR = "☆";
public static readonly StyledProperty<Rating> RatingProperty =
AvaloniaProperty.Register<MyRatingCellEditor, Rating>(nameof(Rating));
public bool IsEditingMode { get; set; }
public Rating Rating { get => GetValue(RatingProperty); set => SetValue(RatingProperty, value); }
public MyRatingCellEditor()
{
InitializeComponent();
if (Design.IsDesignMode)
Rating = new Rating(5, 4, 3);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Rating) && Rating is not null)
{
var blankValue = IsEditingMode ? HOLLOW_STAR : string.Empty;
int rating = 0;
foreach (TextBlock star in panelOverall.Children)
star.Tag = star.Text = Rating.OverallRating > rating++ ? SOLID_STAR : blankValue;
rating = 0;
foreach (TextBlock star in panelPerform.Children)
star.Tag = star.Text = Rating.PerformanceRating > rating++ ? SOLID_STAR : blankValue;
rating = 0;
foreach (TextBlock star in panelStory.Children)
star.Tag = star.Text = Rating.StoryRating > rating++ ? SOLID_STAR : blankValue;
SetVisible();
}
base.OnPropertyChanged(change);
}
private void SetVisible()
{
ratingsGrid.IsEnabled = IsEditingMode;
tblockOverall.IsVisible = panelOverall.IsVisible = IsEditingMode || Rating?.OverallRating > 0;
tblockPerform.IsVisible = panelPerform.IsVisible = IsEditingMode || Rating?.PerformanceRating > 0;
tblockStory.IsVisible = panelStory.IsVisible = IsEditingMode || Rating?.StoryRating > 0;
}
public void Panel_PointerExited(object sender, Avalonia.Input.PointerEventArgs e)
{
var panel = sender as Panel;
var stackPanel = panel.Children.OfType<StackPanel>().Single();
//Restore defaults
foreach (TextBlock child in stackPanel.Children)
child.Text = (string)child.Tag;
}
public void Star_PointerEntered(object sender, Avalonia.Input.PointerEventArgs e)
{
var thisTbox = sender as TextBlock;
var stackPanel = thisTbox.Parent as StackPanel;
var star = SOLID_STAR;
foreach (TextBlock child in stackPanel.Children)
{
child.Text = star;
if (child == thisTbox) star = HOLLOW_STAR;
}
}
public void Star_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
{
var overall = Rating.OverallRating;
var perform = Rating.PerformanceRating;
var story = Rating.StoryRating;
var thisTbox = sender as TextBlock;
var stackPanel = thisTbox.Parent as StackPanel;
int newRatingValue = 0;
foreach (var tbox in stackPanel.Children)
{
newRatingValue++;
if (tbox == thisTbox) break;
}
if (stackPanel == panelOverall)
overall = newRatingValue;
else if (stackPanel == panelPerform)
perform = newRatingValue;
else if (stackPanel == panelStory)
story = newRatingValue;
if (overall + perform + story == 0f) return;
Rating = new Rating(overall, perform, story);
}
}
}

View File

@@ -9,6 +9,9 @@ using System.Linq;
using System.Threading.Tasks;
using ReactiveUI;
using AudibleApi;
using Avalonia.Platform.Storage;
using LibationFileManager;
using Avalonia.Platform.Storage.FileIO;
namespace LibationAvalonia.Dialogs
{
@@ -110,24 +113,29 @@ namespace LibationAvalonia.Dialogs
public async void ImportButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
OpenFileDialog ofd = new();
ofd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } });
ofd.Directory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
ofd.AllowMultiple = false;
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Select the audible-cli [account].json file",
AllowMultiple = false,
SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)),
FileTypeFilter = new FilePickerFileType[]
{
new("JSON files (*.json)") { Patterns = new[] { "json" } },
}
};
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
ofd.Directory = audibleAppDataDir;
openFileDialogOptions.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir);
var filePath = await ofd.ShowAsync(this);
var selectedFiles = await StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault();
if (filePath is null || filePath.Length == 0) return;
if (selectedFile?.TryGetUri(out var uri) is not true) return;
try
{
var jsonText = File.ReadAllText(filePath[0]);
var jsonText = File.ReadAllText(uri.LocalPath);
var mkbAuth = Mkb79Auth.FromJson(jsonText);
var account = await mkbAuth.ToAccountAsync();
@@ -148,7 +156,7 @@ namespace LibationAvalonia.Dialogs
{
await MessageBox.ShowAdminAlert(
this,
$"An error occurred while importing an account from:\r\n{filePath[0]}\r\n\r\nIs the file encrypted?",
$"An error occurred while importing an account from:\r\n{uri.LocalPath}\r\n\r\nIs the file encrypted?",
"Error Importing Account",
ex);
}
@@ -263,26 +271,36 @@ namespace LibationAvalonia.Dialogs
return;
}
SaveFileDialog sfd = new();
sfd.Filters.Add(new() { Name = "JSON File", Extensions = new() { "json" } });
var options = new FilePickerSaveOptions
{
Title = $"Save Sover Image",
SuggestedStartLocation = new BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData)),
SuggestedFileName = $"{acc.AccountId}.json",
DefaultExtension = "json",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("JSON files (*.json)") { Patterns = new[] { "json" } },
}
};
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
sfd.Directory = audibleAppDataDir;
options.SuggestedStartLocation = new BclStorageFolder(audibleAppDataDir);
string fileName = await sfd.ShowAsync(this);
if (fileName is null)
return;
var selectedFile = await StorageProvider.SaveFilePickerAsync(options);
if (selectedFile?.TryGetUri(out var uri) is not true) return;
try
{
var mkbAuth = Mkb79Auth.FromAccount(account);
var jsonText = mkbAuth.ToJson();
File.WriteAllText(fileName, jsonText);
File.WriteAllText(uri.LocalPath, jsonText);
await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{fileName}", "Success!");
await MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{uri.LocalPath}", "Success!");
}
catch (Exception ex)
{

View File

@@ -10,6 +10,7 @@ using LibationAvalonia.ViewModels;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System;
namespace LibationAvalonia.Dialogs
{
@@ -54,7 +55,7 @@ namespace LibationAvalonia.Dialogs
base.SaveAndClose();
}
public void GoToAudible_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public void GoToAudible_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
{
var locale = AudibleApi.Localization.Get(_libraryBook.Book.Locale);
var link = $"https://www.audible.{locale.TopDomain}/pd/{_libraryBook.Book.AudibleProductId}";

View File

@@ -0,0 +1,139 @@
<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="700" d:DesignHeight="450"
Width="700" Height="450"
x:Class="LibationAvalonia.Dialogs.BookRecordsDialog"
Title="BookRecordsDialog"
Icon="/Assets/libation.ico">
<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"
CanUserResizeColumns="True"
CanUserSortColumns="True"
AutoGenerateColumns="False"
IsReadOnly="False"
Items="{Binding DataGridCollectionView}"
GridLinesVisibility="All">
<DataGrid.Styles>
<Style Selector="DataGridColumnHeader">
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
<Style Selector="DataGridCell">
<Setter Property="HorizontalContentAlignment" Value="Center" />
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridCheckBoxColumn
Width="Auto"
IsReadOnly="False"
Binding="{Binding IsChecked, Mode=TwoWay}"
Header="Checked"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Type}"
Header="Type"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Created}"
Header="Created"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Start}"
Header="Start"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Modified}"
Header="Modified"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding End}"
Header="End"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Note}"
Header="Note"/>
<DataGridTextColumn
Width="Auto"
IsReadOnly="True"
Binding="{Binding Title}"
Header="Title"/>
</DataGrid.Columns>
</DataGrid>
<Grid
Grid.Row="1"
Margin="10"
ColumnDefinitions="Auto,Auto,*,Auto"
RowDefinitions="Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="HorizontalContentAlignment" Value="Center"/>
<Setter Property="Margin" Value="0,10,0,0"/>
<Setter Property="Height" Value="30"/>
</Style>
</Grid.Styles>
<Button
Grid.Column="0"
Grid.Row="0"
Content="Check All"
Click="CheckAll_Click"/>
<Button
Grid.Column="0"
Grid.Row="1"
Content="Uncheck All"
Click="UncheckAll_Click"/>
<Button
Grid.Column="1"
Grid.Row="0"
Margin="20,10,0,0"
Content="Delete Checked"
Click="DeleteChecked_Click"/>
<Button
Grid.Column="1"
Grid.Row="1"
Margin="20,10,0,0"
Content="Reload All"
Click="ReloadAll_Click"/>
<Button
Grid.Column="3"
Grid.Row="0"
Content="Export Checked"
Click="ExportChecked_Click"/>
<Button
Grid.Column="3"
Grid.Row="1"
Content="Export All"
Click="ExportAll_Click"/>
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,219 @@
using ApplicationServices;
using AudibleApi.Common;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Platform.Storage;
using Avalonia.Threading;
using DataLayer;
using FileLiberator;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
{
public partial class BookRecordsDialog : DialogWindow
{
public DataGridCollectionView DataGridCollectionView { get; }
private readonly AvaloniaList<BookRecordEntry> bookRecordEntries = new();
private readonly LibraryBook libraryBook;
public BookRecordsDialog()
{
InitializeComponent();
if (Design.IsDesignMode)
{
bookRecordEntries.Add(new BookRecordEntry(new Clip(DateTimeOffset.Now.AddHours(1), TimeSpan.FromHours(6.8667), "xxxxxxx", DateTimeOffset.Now.AddHours(1), TimeSpan.FromHours(6.8668), "Note 2", "title 2")));
bookRecordEntries.Add(new BookRecordEntry(new Clip(DateTimeOffset.Now, TimeSpan.FromHours(4.5667), "xxxxxxx", DateTimeOffset.Now, TimeSpan.FromHours(4.5668), "Note", "title")));
}
DataGridCollectionView = new DataGridCollectionView(bookRecordEntries);
DataContext = this;
}
public BookRecordsDialog(LibraryBook libraryBook) : this()
{
this.libraryBook = libraryBook;
Title = $"{libraryBook.Book.Title} - Clips and Bookmarks";
Loaded += BookRecordsDialog_Loaded;
}
private async void BookRecordsDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries.AddRange(records.Select(r => new BookRecordEntry(r)));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to retrieve records for {libraryBook}", libraryBook);
}
}
#region Buttons
private async Task setControlEnabled(object control, bool enabled)
{
if (control is InputElement c)
await Dispatcher.UIThread.InvokeAsync(() => c.IsEnabled = enabled);
}
public async void ExportChecked_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record));
await setControlEnabled(sender, true);
}
public async void ExportAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
await saveRecords(bookRecordEntries.Select(r => r.Record));
await setControlEnabled(sender, true);
}
public void CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = true;
}
public void UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var record in bookRecordEntries)
record.IsChecked = false;
}
public async void DeleteChecked_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var records = bookRecordEntries.Where(r => r.IsChecked).Select(r => r.Record).ToList();
if (!records.Any()) return;
await setControlEnabled(sender, false);
bool success = false;
try
{
var api = await libraryBook.GetApiAsync();
success = await api.DeleteRecordsAsync(libraryBook.Book.AudibleProductId, records);
records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
var removed = bookRecordEntries.ExceptBy(records, r => r.Record).ToList();
foreach (var r in removed)
bookRecordEntries.Remove(r);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, ex.Message);
}
finally { await setControlEnabled(sender, true); }
if (!success)
await MessageBox.Show(this, $"Libation was unable to delete the {records.Count} selected records", "Deletion Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
public async void ReloadAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
await setControlEnabled(sender, false);
try
{
var api = await libraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(libraryBook.Book.AudibleProductId);
bookRecordEntries.Clear();
bookRecordEntries.AddRange(records.Select(r => new BookRecordEntry(r)));
}
catch (Exception ex)
{
Serilog.Log.Error(ex, ex.Message);
await MessageBox.Show(this, $"Libation was unable to reload records", "Reload Failed", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally { await setControlEnabled(sender, true); }
}
#endregion
private async Task saveRecords(IEnumerable<IRecord> records)
{
if (!records.Any()) return;
try
{
var saveFileDialog =
await Dispatcher.UIThread.InvokeAsync(() => new FilePickerSaveOptions
{
Title = "Where to export book records",
SuggestedFileName = $"{libraryBook.Book.Title} - Records",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "*.xlsx" } },
new("CSV files (*.csv)") { Patterns = new[] { "*.csv" } },
new("JSON files (*.json)") { Patterns = new[] { "*.json" } },
new("All files (*.*)") { Patterns = new[] { "*" } }
}
});
var selectedFile = await StorageProvider.SaveFilePickerAsync(saveFileDialog);
if (selectedFile?.TryGetUri(out var uri) is not true) return;
var ext = System.IO.Path.GetExtension(uri.LocalPath).ToLowerInvariant();
switch (ext)
{
case ".xlsx":
default:
await Task.Run(() => RecordExporter.ToXlsx(uri.LocalPath, records));
break;
case ".csv":
await Task.Run(() => RecordExporter.ToCsv(uri.LocalPath, records));
break;
case ".json":
await Task.Run(() => RecordExporter.ToJson(uri.LocalPath, libraryBook, records));
break;
}
}
catch (Exception ex)
{
await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
}
}
#region DataGrid Bindings
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 string Type => Record.GetType().Name;
public string Start => formatTimeSpan(Record.Start);
public string Created => Record.Created.ToString(DateFormat);
public string Modified => Record is IAnnotation annotation ? annotation.Created.ToString(DateFormat) : string.Empty;
public string End => Record is IRangeAnnotation range ? formatTimeSpan(range.End) : string.Empty;
public string Note => Record is IRangeAnnotation range ? range.Text : string.Empty;
public string Title => Record is Clip range ? range.Title : string.Empty;
public BookRecordEntry(IRecord record) => Record = record;
private static string formatTimeSpan(TimeSpan timeSpan)
{
int h = (int)timeSpan.TotalHours;
int m = timeSpan.Minutes;
int s = timeSpan.Seconds;
int ms = timeSpan.Milliseconds;
return ms == 0 ? $"{h:d2}:{m:d2}:{s:d2}" : $"{h:d2}:{m:d2}:{s:d2}.{ms:d3}";
}
}
#endregion
}
}

View File

@@ -2,60 +2,71 @@
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="800" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="450"
MinWidth="500" MinHeight="450"
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
Title="EditReplacementChars">
Title="Illegal Character Replacement"
Icon="/Assets/libation.ico">
<DataGrid
GridLinesVisibility="All"
AutoGenerateColumns="False"
Items="{Binding replacements}">
<Grid
RowDefinitions="*,Auto"
ColumnDefinitions="*,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"
Items="{Binding replacements}">
<DataGrid.Columns>
<DataGridTextColumn
IsReadOnly="False"
Binding="{Binding CharacterToReplace, Mode=TwoWay}"
Header="Char to&#xa;Replace"/>
<DataGridTextColumn
IsReadOnly="False"
Binding="{Binding ReplacementText, Mode=TwoWay}"
Header="Replacement&#xa;Text"/>
<DataGridTextColumn Width="*"
IsReadOnly="False"
Binding="{Binding Description, Mode=TwoWay}"
Header="Description"/>
</DataGrid.Columns>
</DataGrid>
<StackPanel
Grid.Row="1"
Grid.Column="0"
Margin="5"
Orientation="Horizontal">
<Button Margin="0,0,10,0" Click="Defaults_Click" Content="Defaults" />
<Button Margin="0,0,10,0" Click="LoFiDefaults_Click" Content="LoFi Defaults" />
<Button Click="Barebones_Click" Content="Barebones" />
</StackPanel>
<StackPanel
Grid.Row="1"
Grid.Column="1"
Margin="5"
Orientation="Horizontal">
<Button Margin="0,0,10,0" Click="Cancel_Click" Content="Cancel" />
<Button Padding="20,5,20,6" Click="Save_Click" Content="Save" />
</StackPanel>
<DataGrid.Columns>
<DataGridTemplateColumn Width="Auto" Header="Char to&#xa;Replace">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center"
FontFamily="SEGOEUI_Local"
Text="{Binding Replacement.CharacterToReplace}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn IsReadOnly="False" Width="Auto" Header="Replacement Text">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<Grid RowDefinitions="*" ColumnDefinitions="*">
<TextBox
Grid.Column="0"
Grid.Row="0"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
FontSize="14"
FontFamily="SEGOEUI_Local"
Foreground="{StaticResource SystemControlTransparentBrush}"
SelectionBrush="{StaticResource SystemControlTransparentBrush}"
BorderBrush="{StaticResource SystemControlTransparentBrush}"
Text="{Binding ReplacementText, Mode=TwoWay}" />
<TextBlock
Grid.Column="0"
Grid.Row="0"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
FontSize="14"
FontFamily="SEGOEUI_Local"
Text="{Binding ReplacementText}" />
</Grid>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>

View File

@@ -1,54 +1,179 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using FileManager;
using LibationFileManager;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using ReactiveUI;
using System.Linq;
using Avalonia.Collections;
using Avalonia.Data;
namespace LibationAvalonia.Dialogs
{
public partial class EditReplacementChars : DialogWindow
{
Configuration config = Configuration.Instance;
public ObservableCollection<ReplacementsExt> replacements { get; }
Configuration config;
private readonly List<ReplacementsExt> SOURCE = new();
public DataGridCollectionView replacements { get; }
public EditReplacementChars()
{
InitializeComponent();
if (Design.IsDesignMode)
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
replacements = new(SOURCE);
if (Design.IsDesignMode)
{
LoadTable(ReplacementCharacters.Default.Replacements);
}
replacements = new(config.ReplacementCharacters.Replacements.Select(r => new ReplacementsExt { Replacement = r }));
DataContext = this;
}
public EditReplacementChars(Configuration config) : this()
{
this.config = config;
LoadTable(config.ReplacementCharacters.Replacements);
}
public void Defaults_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> LoadTable(ReplacementCharacters.Default.Replacements);
public void LoFiDefaults_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> LoadTable(ReplacementCharacters.LoFiDefault.Replacements);
public void Barebones_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> LoadTable(ReplacementCharacters.Barebones.Replacements);
public void Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> SaveAndClose();
public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> Close();
protected override 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 };
base.SaveAndClose();
}
private void LoadTable(IReadOnlyList<Replacement> replacements)
{
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)
{
if (e.Key == Avalonia.Input.Key.Delete
&& ((DataGrid)sender).SelectedItem is ReplacementsExt repl
&& !repl.Mandatory
&& !repl.IsDefault)
{
replacements.Remove(repl);
}
}
public 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))
{
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))
e.Cancel = true;
}
private static string columnBindingPath(DataGridColumn column)
=> ((Binding)((DataGridBoundColumn)column).Binding).Path;
public class ReplacementsExt : ViewModels.ViewModelBase
{
public Replacement Replacement { get; init; }
public ReplacementsExt()
{
_replacementText = string.Empty;
_description = string.Empty;
_characterToReplace = string.Empty;
IsDefault = true;
}
public ReplacementsExt(Replacement replacement)
{
_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 bool Mandatory { get; }
public string ReplacementText
{
get => Replacement.ReplacementString;
get => _replacementText;
set
{
Replacement.ReplacementString = value;
this.RaisePropertyChanged(nameof(ReplacementText));
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; }
}
private void InitializeComponent()
{
AvaloniaXamlLoader.Load(this);
}
private void LoadTable(IReadOnlyList<Replacement> replacements)
{
}
}
}

View File

@@ -8,11 +8,6 @@
Icon="/Assets/libation.ico"
Title="EditTemplateDialog">
<Window.Resources>
<dialogs:BracketEscapeConverter x:Key="BracketEscapeConverter" />
</Window.Resources>
<Grid RowDefinitions="Auto,*,Auto">
<Grid
Grid.Row="0"
@@ -27,37 +22,36 @@
<TextBox
Grid.Column="0"
Grid.Row="1"
Text="{Binding workingTemplateText, Mode=TwoWay}" />
Name="userEditTbox"
FontFamily="{Binding FontFamily}"
Text="{Binding UserTemplateText, Mode=TwoWay}" />
<Button
Grid.Column="1"
Grid.Row="1"
Margin="10,0,0,0"
VerticalAlignment="Stretch"
Padding="20,3,20,3"
VerticalContentAlignment="Center"
Content="Reset to Default"
Click="ResetButton_Click" />
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Border
<DataGrid
Grid.Row="0"
Grid.Column="0"
Margin="5"
BorderBrush="{DynamicResource DataGridGridLinesBrush}"
BorderThickness="1"
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
<DataGrid
GridLinesVisibility="All"
AutoGenerateColumns="False"
DoubleTapped="EditTemplateViewModel_DoubleTapped"
Items="{Binding ListItems}" >
<DataGrid.Columns>
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding TagName, Converter={StaticResource BracketEscapeConverter}}" />
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -68,7 +62,7 @@
<TextPresenter
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Description}" />
VerticalAlignment="Center" Text="{Binding Item2}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -76,13 +70,12 @@
</DataGrid.Columns>
</DataGrid>
</Border>
<Grid
Grid.Column="1"
Margin="5"
RowDefinitions="Auto,*,80" HorizontalAlignment="Stretch">
Margin="5,0,5,0"
RowDefinitions="Auto,*,Auto"
HorizontalAlignment="Stretch">
<TextBlock
Margin="5,5,5,10"
@@ -94,10 +87,9 @@
BorderThickness="1"
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
<WrapPanel
Grid.Row="1"
Name="wrapPanel"
Orientation="Horizontal" />
<TextBlock
TextWrapping="WrapWithOverflow"
Inlines="{Binding Inlines}" />
</Border>
@@ -105,10 +97,9 @@
Grid.Row="2"
Margin="5"
Foreground="Firebrick"
Text="{Binding WarningText}" />
Text="{Binding WarningText}"
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</Grid>
<Button
Grid.Row="2"

View File

@@ -1,38 +1,19 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Data;
using Avalonia.Data.Converters;
using Avalonia.Markup.Xaml;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Media.TextFormatting;
using Dinah.Core;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ReactiveUI;
using Avalonia.Controls.Documents;
using Avalonia.Collections;
using Avalonia.Controls;
namespace LibationAvalonia.Dialogs
{
class BracketEscapeConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string str && str[0] != '<' && str[^1] != '>')
return $"<{str}>";
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
{
if (value is string str && str[0] == '<' && str[^1] == '>')
return str[1..^2];
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
}
public partial class EditTemplateDialog : DialogWindow
{
// final value. post-validity check
@@ -42,26 +23,40 @@ namespace LibationAvalonia.Dialogs
public EditTemplateDialog()
{
InitializeComponent();
_viewModel = new(Configuration.Instance, this.Find<WrapPanel>(nameof(wrapPanel)));
AvaloniaXamlLoader.Load(this);
userEditTbox = this.FindControl<TextBox>(nameof(userEditTbox));
if (Design.IsDesignMode)
{
AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists();
_viewModel = new(Configuration.Instance, Templates.File);
_viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
Title = $"Edit {_viewModel.Template.Name}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(Templates template, string inputTemplateText) : this()
{
_viewModel.template = ArgumentValidator.EnsureNotNull(template, nameof(template));
Title = $"Edit {_viewModel.template.Name}";
_viewModel.Description = _viewModel.template.Description;
ArgumentValidator.EnsureNotNull(template, nameof(template));
_viewModel = new EditTemplateViewModel(Configuration.Instance, template);
_viewModel.resetTextBox(inputTemplateText);
_viewModel.ListItems = _viewModel.template.GetTemplateTags();
Title = $"Edit {template.Name}";
DataContext = _viewModel;
}
private void InitializeComponent()
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
{
AvaloniaXamlLoader.Load(this);
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string>).Item1.Replace("\x200C", "").Replace("...", "");
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
{
if (!await _viewModel.Validate())
@@ -75,51 +70,59 @@ namespace LibationAvalonia.Dialogs
=> await SaveAndCloseAsync();
public void ResetButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.resetTextBox(_viewModel.template.DefaultTemplate);
=> _viewModel.resetTextBox(_viewModel.Template.DefaultTemplate);
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
WrapPanel WrapPanel;
public Configuration config { get; }
public EditTemplateViewModel(Configuration configuration, WrapPanel panel)
private readonly Configuration config;
public FontFamily FontFamily { get; } = FontManager.Current.DefaultFontFamilyName;
public InlineCollection Inlines { get; } = new();
public Templates Template { get; }
public EditTemplateViewModel(Configuration configuration, Templates templates)
{
config = configuration;
WrapPanel = panel;
Template = templates;
Description = templates.Description;
ListItems
= new AvaloniaList<Tuple<string, string>>(
Template
.GetTemplateTags()
.Select(
t => new Tuple<string, string>(
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
t.Description)
)
);
}
// hold the work-in-progress value. not guaranteed to be valid
private string _workingTemplateText;
public string workingTemplateText
private string _userTemplateText;
public string UserTemplateText
{
get => _workingTemplateText;
get => _userTemplateText;
set
{
_workingTemplateText = template.Sanitize(value);
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
}
public string workingTemplateText => Template.Sanitize(UserTemplateText);
private string _warningText;
public string WarningText
{
get => _warningText;
set
{
this.RaiseAndSetIfChanged(ref _warningText, value);
}
}
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public Templates template { get; set; }
public string Description { get; set; }
public string Description { get; }
public IEnumerable<TemplateTags> ListItems { get; set; }
public AvaloniaList<Tuple<string, string>> ListItems { get; set; }
public void resetTextBox(string value) => workingTemplateText = value;
public void resetTextBox(string value) => UserTemplateText = value;
public async Task<bool> Validate()
{
if (template.IsValid(workingTemplateText))
if (Template.IsValid(workingTemplateText))
return true;
var errors = template
var errors = Template
.GetErrors(workingTemplateText)
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
@@ -129,8 +132,8 @@ namespace LibationAvalonia.Dialogs
private void templateTb_TextChanged()
{
var isChapterTitle = template == Templates.ChapterTitle;
var isFolder = template == Templates.Folder;
var isChapterTitle = Template == Templates.ChapterTitle;
var isFolder = Template == Templates.Folder;
var libraryBookDto = new LibraryBookDto
{
@@ -142,7 +145,10 @@ namespace LibationAvalonia.Dialogs
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
Narrators = new List<string> { "Stephen Fry" },
SeriesName = "Sherlock Holmes",
SeriesNumber = "1"
SeriesNumber = "1",
BitRate = 128,
SampleRate = 44100,
Channels = 2
};
var chapterName = "A Flight for Life";
var chapterNumber = 4;
@@ -159,18 +165,24 @@ namespace LibationAvalonia.Dialogs
var books = config.Books;
var folder = Templates.Folder.GetPortionFilename(
libraryBookDto,
isFolder ? workingTemplateText : config.FolderTemplate);
//Path must be rooted for windows to allow long file paths. This is
//only necessary for folder templates because they may contain several
//subdirectories. Without rooting, we won't be allowed to create a
//relative path longer than MAX_PATH
Path.Combine(books, isFolder ? workingTemplateText : config.FolderTemplate));
folder = Path.GetRelativePath(books, folder);
var file
= template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
= Template == Templates.ChapterFile
? Templates.ChapterFile.GetPortionFilename(
libraryBookDto,
workingTemplateText,
partFileProperties,
"")
: Templates.File.GetPortionFilename(
libraryBookDto,
isFolder ? config.FileTemplate : workingTemplateText);
var ext = config.DecryptToLossy ? "mp3" : "m4b";
var chapterTitle = Templates.ChapterTitle.GetPortionTitle(libraryBookDto, workingTemplateText, partFileProperties);
@@ -186,63 +198,36 @@ namespace LibationAvalonia.Dialogs
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !template.HasWarnings(workingTemplateText)
= !Template.HasWarnings(workingTemplateText)
? ""
: "Warning:\r\n" +
template
Template
.GetWarnings(workingTemplateText)
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
var list = new List<TextCharacters>();
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
var bold = new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, FontWeight.Bold);
var normal = new Typeface(Typeface.Default.FontFamily, FontStyle.Normal, FontWeight.Normal);
var stringList = new List<(string, FontWeight)>();
Inlines.Clear();
if (isChapterTitle)
{
stringList.Add((chapterTitle, FontWeight.Bold));
}
else
{
stringList.Add((slashWrap(books), FontWeight.Normal));
stringList.Add((sing, FontWeight.Normal));
stringList.Add((slashWrap(folder), isFolder ? FontWeight.Bold : FontWeight.Normal));
stringList.Add((sing, FontWeight.Normal));
stringList.Add((file, !isFolder ? FontWeight.Bold : FontWeight.Normal));
stringList.Add(($".{ext}", FontWeight.Normal));
Inlines.Add(new Run(chapterTitle) { FontWeight = bold });
return;
}
WrapPanel.Children.Clear();
Inlines.Add(new Run(slashWrap(books)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
//Avalonia doesn't yet support anything like rich text, so add a new textblock for every word/style
foreach (var item in stringList)
{
var wordsSplit = item.Item1.Split(' ');
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = isFolder ? bold : reg });
for(int i = 0; i < wordsSplit.Length; i++)
{
var tb = new TextBlock
{
VerticalAlignment = Avalonia.Layout.VerticalAlignment.Bottom,
TextWrapping = TextWrapping.Wrap,
Text = wordsSplit[i] + (i == wordsSplit.Length - 1 ? "" : " "),
FontWeight = item.Item2
};
Inlines.Add(new Run(sing));
WrapPanel.Children.Add(tb);
}
}
Inlines.Add(new Run(slashWrap(file)) { FontWeight = isFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}
}
}
}

View File

@@ -7,6 +7,7 @@ using System;
using System.ComponentModel;
using System.IO;
using ReactiveUI;
using Avalonia.Platform.Storage;
namespace LibationAvalonia.Dialogs
{
@@ -46,27 +47,30 @@ namespace LibationAvalonia.Dialogs
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var options = new FilePickerSaveOptions
{
Title = $"Save Sover Image",
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)),
SuggestedFileName = PictureFileName,
DefaultExtension = "jpg",
ShowOverwritePrompt = true,
FileTypeChoices = new FilePickerFileType[]
{
new("Jpeg (*.jpg)") { Patterns = new[] { "jpg" } }
}
};
SaveFileDialog saveFileDialog = new();
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Jpeg", Extensions = new System.Collections.Generic.List<string>() { "jpg" } });
saveFileDialog.InitialFileName = PictureFileName;
saveFileDialog.Directory
= !LibationFileManager.Configuration.IsWindows ? null
: Directory.Exists(BookSaveDirectory) ? BookSaveDirectory
: Path.GetDirectoryName(BookSaveDirectory);
var selectedFile = await StorageProvider.SaveFilePickerAsync(options);
var fileName = await saveFileDialog.ShowAsync(this);
if (fileName is null)
return;
if (selectedFile?.TryGetUri(out var uri) is not true) return;
try
{
File.WriteAllBytes(fileName, CoverBytes);
File.WriteAllBytes(uri.LocalPath, CoverBytes);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"Failed to save picture to {fileName}");
Serilog.Log.Logger.Error(ex, $"Failed to save picture to {uri.LocalPath}");
await MessageBox.Show(this, $"An error was encountered while trying to save the picture\r\n\r\n{ex.Message}", "Failed to save picture", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
}
}

View File

@@ -34,7 +34,13 @@ namespace LibationAvalonia.Dialogs
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" },
};
public LiberatedStatusBatchManualDialog()
public LiberatedStatusBatchManualDialog(bool isPdf) : this()
{
if (isPdf)
this.Title = this.Title.Replace("book", "PDF");
}
public LiberatedStatusBatchManualDialog()
{
InitializeComponent();
SelectedItem = BookStatuses[0] as liberatedComboBoxItem;

View File

@@ -31,7 +31,7 @@ namespace LibationAvalonia.Dialogs.Login
DataContext = this;
}
public async void ExternalLoginLink_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public async void ExternalLoginLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
{
LoginMethod = LoginMethod.External;
await SaveAndCloseAsync();

View File

@@ -28,7 +28,7 @@ namespace LibationAvalonia.Dialogs
DataContext = this;
}
private async void GoToGithub_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private async void GoToGithub_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
{
var url = "https://github.com/rmcrackan/Libation/issues";
try
@@ -41,7 +41,7 @@ namespace LibationAvalonia.Dialogs
}
}
private async void GoToLogs_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private async void GoToLogs_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
{
LongPath dir = "";
try

View File

@@ -6,7 +6,7 @@
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
MinWidth="265" MinHeight="110"
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
Title="{Binding Caption}" HasSystemDecorations="True" ShowInTaskbar="True"
Title="{Binding Caption}" ShowInTaskbar="True"
Icon="/Assets/1x1.png">
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
@@ -34,13 +34,13 @@
</Style>
</DockPanel.Styles>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button Grid.Column="0" MinWidth="75" MinHeight="25" Name="Button1" Click="Button1_Click" Margin="5">
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button1Text}"/>
</Button>
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="25" Name="Button2" Click="Button2_Click" Margin="5">
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button2Text}"/>
</Button>
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="25" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
<TextBlock VerticalAlignment="Center" Text="{Binding Button3Text}"/>
</Button>
</StackPanel>

View File

@@ -2,9 +2,9 @@
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="950" d:DesignHeight="550"
MinWidth="950" MinHeight="550"
MaxWidth="950" MaxHeight="550"
mc:Ignorable="d" d:DesignWidth="950" d:DesignHeight="650"
MinWidth="950" MinHeight="650"
MaxWidth="950" MaxHeight="650"
x:Class="LibationAvalonia.Dialogs.SearchSyntaxDialog"
Title="Filter Options"
WindowStartupLocation="CenterOwner"

View File

@@ -37,10 +37,13 @@ Find books that you haven't rated:
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchBoolFields());
IdFields = @"
Alice's Adventures in Wonderland (ID: B015D78L0U)
Alice's Adventures in
Wonderland (ID: B015D78L0U)
id:B015D78L0U
All of these are synonyms for the ID field
All of these are synonyms
for the ID field
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchIdFields());

View File

@@ -2,7 +2,7 @@
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="800" d:DesignHeight="620"
mc:Ignorable="d" d:DesignWidth="850" d:DesignHeight="620"
MinWidth="800" MinHeight="620"
x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
@@ -23,12 +23,12 @@
<TabControl Grid.Column="0">
<TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="18"/>
<Setter Property="Height" Value="28"/>
</Style>
<Style Selector="TabItem">
<Setter Property="MinHeight" Value="30"/>
<Setter Property="Height" Value="30"/>
<Setter Property="Padding" Value="8,2,8,0"/>
<Setter Property="MinHeight" Value="40"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,10"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
@@ -102,7 +102,7 @@
Click="OpenLogFolderButton_Click" />
</StackPanel>
<!--
<!--
<CheckBox
Grid.Row="2"
Margin="5"
@@ -282,7 +282,7 @@
Grid.Column="0"
Margin="0,5,0,0"
Text="{Binding DownloadDecryptSettings.FolderTemplateText}" />
<TextBox
Grid.Row="1"
Grid.Column="0"
@@ -344,7 +344,6 @@
<Button
Grid.Row="6"
Grid.Column="0"
IsEnabled="False"
Content="{Binding DownloadDecryptSettings.EditCharReplacementText}"
Height="30"
Padding="30,3,30,3"
@@ -352,21 +351,27 @@
</Grid>
</controls:GroupBox>
<StackPanel
<controls:GroupBox
Grid.Row="2"
Margin="5" >
Margin="5"
BorderWidth="1"
Label="Temporary Files Location">
<TextBlock
Margin="0,0,0,10"
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
<StackPanel
Margin="5" >
<controls:DirectorySelectControl
SubDirectory="Libation\DecryptInProgress"
SelectedDirectory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}" />
<TextBlock
Margin="0,0,0,10"
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
<controls:DirectoryOrCustomSelectControl
SubDirectory="Libation"
Directory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}"
KnownDirectories="{Binding DownloadDecryptSettings.KnownDirectories}" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
<CheckBox
Grid.Row="3"
@@ -431,6 +436,26 @@
</CheckBox>
<StackPanel Orientation="Horizontal">
<CheckBox
Margin="0,0,0,5"
IsChecked="{Binding AudioSettings.DownloadClipsBookmarks, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Download Clips, Notes and Bookmarks as" />
</CheckBox>
<controls:WheelComboBox
Margin="5,0,0,0"
IsEnabled="{Binding AudioSettings.DownloadClipsBookmarks}"
Items="{Binding AudioSettings.ClipBookmarkFormats}"
SelectedItem="{Binding AudioSettings.ClipBookmarkFormat}"/>
</StackPanel>
<CheckBox
Margin="0,0,0,5"
IsChecked="{Binding AudioSettings.RetainAaxFile, Mode=TwoWay}">

View File

@@ -9,6 +9,8 @@ using ReactiveUI;
using Dinah.Core;
using System.Linq;
using FileManager;
using System.IO;
using Avalonia.Collections;
namespace LibationAvalonia.Dialogs
{
@@ -69,13 +71,10 @@ namespace LibationAvalonia.Dialogs
settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate;
}
public void EditCharReplacementButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public async void EditCharReplacementButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
/*
var form = new LibationAvalonia.Dialogs.EditReplacementChars(config);
form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
form.ShowDialog();
*/
var form = new EditReplacementChars(config);
await form.ShowDialog<DialogResult>(this);
}
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
@@ -230,7 +229,6 @@ namespace LibationAvalonia.Dialogs
public class DownloadDecryptSettings : ViewModels.ViewModelBase, ISettingsDisplay
{
private bool _badBookAsk;
private bool _badBookAbort;
private bool _badBookRetry;
@@ -245,7 +243,16 @@ namespace LibationAvalonia.Dialogs
LoadSettings(config);
}
public Configuration.KnownDirectories InProgressDirectory { get; set; }
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.LibationFiles
};
public string InProgressDirectory { get; set; }
public void LoadSettings(Configuration config)
{
BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask;
@@ -255,9 +262,7 @@ namespace LibationAvalonia.Dialogs
FolderTemplate = config.FolderTemplate;
FileTemplate = config.FileTemplate;
ChapterFileTemplate = config.ChapterFileTemplate;
InProgressDirectory
= config.InProgress == Configuration.AppDir_Absolute ? Configuration.KnownDirectories.AppDir
: Configuration.GetKnownDirectory(config.InProgress);
InProgressDirectory = config.InProgress;
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
}
@@ -292,9 +297,7 @@ namespace LibationAvalonia.Dialogs
config.FolderTemplate = FolderTemplate;
config.FileTemplate = FileTemplate;
config.ChapterFileTemplate = ChapterFileTemplate;
config.InProgress
= InProgressDirectory is Configuration.KnownDirectories.AppDir ? Configuration.AppDir_Absolute
: Configuration.GetKnownDirectoryPath(InProgressDirectory);
config.InProgress = InProgressDirectory;
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
@@ -379,6 +382,7 @@ namespace LibationAvalonia.Dialogs
public class AudioSettings : ViewModels.ViewModelBase, ISettingsDisplay
{
private bool _downloadClipsBookmarks;
private bool _splitFilesByChapter;
private bool _allowLibationFixup;
private bool _lameTargetBitrate;
@@ -399,6 +403,8 @@ namespace LibationAvalonia.Dialogs
AllowLibationFixup = config.AllowLibationFixup;
DownloadCoverArt = config.DownloadCoverArt;
RetainAaxFile = config.RetainAaxFile;
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
SplitFilesByChapter = config.SplitFilesByChapter;
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
StripAudibleBrandAudio = config.StripAudibleBrandAudio;
@@ -419,6 +425,8 @@ namespace LibationAvalonia.Dialogs
config.AllowLibationFixup = AllowLibationFixup;
config.DownloadCoverArt = DownloadCoverArt;
config.RetainAaxFile = RetainAaxFile;
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
config.SplitFilesByChapter = SplitFilesByChapter;
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
config.StripAudibleBrandAudio = StripAudibleBrandAudio;
@@ -435,6 +443,7 @@ namespace LibationAvalonia.Dialogs
return Task.FromResult(true);
}
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
public string AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
@@ -448,6 +457,8 @@ namespace LibationAvalonia.Dialogs
public bool CreateCueSheet { get; set; }
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }
public bool StripAudibleBrandAudio { get; set; }
public bool StripUnabridged { get; set; }

View File

@@ -2,17 +2,17 @@
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="330"
MinWidth="500" MinHeight="330"
MaxWidth="500" MaxHeight="330"
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="350"
MinWidth="500" MinHeight="350"
MaxWidth="500" MaxHeight="350"
x:Class="LibationAvalonia.Dialogs.SetupDialog"
WindowStartupLocation="CenterScreen"
Icon="/Assets/libation.ico"
Title="Welcome to Libation">
<Grid Margin="10" RowDefinitions="*,Auto,Auto">
<Grid Margin="10" ColumnDefinitions="*" RowDefinitions="*,Auto,Auto">
<TextBlock Grid.Row="0" Text="This appears to be your first time using Libation or a previous setup was incomplete.
<TextBlock Grid.Row="0" TextWrapping="Wrap" Text="This appears to be your first time using Libation or a previous setup was incomplete.
&#xa;
&#xa;Please fill in a few settings. You can also change these settings later.
&#xa;
@@ -22,11 +22,10 @@
&#xa;Download your entire library from the &quot;Liberate&quot; tab or
&#xa;liberate your books one at a time by clicking the stoplight." />
<Button
<Button
Grid.Row="1"
Margin="0,10,0,10"
Padding="0,10,0,10"
HorizontalAlignment="Stretch"
Width="480"
Margin="0,0,0,10"
Click="NewUser_Click">
<TextBlock
@@ -35,12 +34,11 @@
</Button>
<Button
<Button
Grid.Row="2"
Padding="0,10,0,10"
HorizontalAlignment="Stretch"
Width="480"
Click="ReturningUser_Click">
<TextBlock
TextAlignment="Center"
Text="RETURNING USER&#xa;&#xa;I have previously installed Libation. This is an upgrade or re-install."/>

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