Compare commits
150 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3f6689d032 | ||
|
|
b4206fc203 | ||
|
|
cfa4a0c07f | ||
|
|
357b220ace | ||
|
|
47968304c9 | ||
|
|
2024d5e116 | ||
|
|
5ae2a99c14 | ||
|
|
7fd002d2c9 | ||
|
|
b7b7038244 | ||
|
|
b5519c4875 | ||
|
|
44feab9eb2 | ||
|
|
96c45c33e5 | ||
|
|
36efbcb812 | ||
|
|
03f44b4e9c | ||
|
|
19860e9f09 | ||
|
|
0701cb3970 | ||
|
|
7d6000e3b6 | ||
|
|
ef973ac56a | ||
|
|
91a1033c52 | ||
|
|
4197db6af9 | ||
|
|
210ab065c2 | ||
|
|
9cd10eca58 | ||
|
|
ba676be46d | ||
|
|
665a2e1866 | ||
|
|
94469cae3d | ||
|
|
a0dd2ccad6 | ||
|
|
b2cf837de7 | ||
|
|
80bcf60b5b | ||
|
|
7ad0ab566a | ||
|
|
2b16e86c7b | ||
|
|
f2ea02ae0b | ||
|
|
f65cd39040 | ||
|
|
5ca0d2a399 | ||
|
|
d1528a095b | ||
|
|
749173a463 | ||
|
|
6fbd90a6b3 | ||
|
|
f39d272e6a | ||
|
|
bb3854f512 | ||
|
|
e40daecfb8 | ||
|
|
3716ab9cb5 | ||
|
|
0cc6d6337a | ||
|
|
ce711a36ba | ||
|
|
451af7bea9 | ||
|
|
63200592bf | ||
|
|
d165dfbeb5 | ||
|
|
eed3d84517 | ||
|
|
ba7d890966 | ||
|
|
5140fc63d9 | ||
|
|
78509c07e0 | ||
|
|
5084141215 | ||
|
|
3f2ac83474 | ||
|
|
58a0468728 | ||
|
|
8e13aa7513 | ||
|
|
48e2d91fc8 | ||
|
|
a7f119217f | ||
|
|
865f2261fe | ||
|
|
dfedb23efd | ||
|
|
c01e1c3e4b | ||
|
|
ad8dac5fb0 | ||
|
|
84e81b6218 | ||
|
|
86efe631fe | ||
|
|
f5f1dc483b | ||
|
|
8aa4328c6c | ||
|
|
a01a8c4b19 | ||
|
|
4b2387b621 | ||
|
|
74d16d8ef9 | ||
|
|
b1ea8f9fa7 | ||
|
|
c666fdeaff | ||
|
|
7068782975 | ||
|
|
c4cebbebe7 | ||
|
|
53d43d9fa9 | ||
|
|
11d59beeed | ||
|
|
ef71e297f4 | ||
|
|
1e4d1d1973 | ||
|
|
893d99854b | ||
|
|
db93980cd5 | ||
|
|
34fac30b2b | ||
|
|
2fa0bcb765 | ||
|
|
78fd09aa91 | ||
|
|
a54516b4f5 | ||
|
|
f193d6f376 | ||
|
|
8a82c294a1 | ||
|
|
9392cf4bf0 | ||
|
|
ec4deb9099 | ||
|
|
cf0548aab9 | ||
|
|
064801380b | ||
|
|
9dc2a7424a | ||
|
|
4c8a56a5b9 | ||
|
|
9aad263996 | ||
|
|
ce1ab7c20d | ||
|
|
c9217990cd | ||
|
|
90cbf3b7a6 | ||
|
|
c4f1b22ddf | ||
|
|
fb612ea6ab | ||
|
|
bce44b6f6d | ||
|
|
7575736991 | ||
|
|
06f8d055fc | ||
|
|
d64e043fe8 | ||
|
|
99564d9c25 | ||
|
|
29bccd3e33 | ||
|
|
20f65f6534 | ||
|
|
8ca72b2e2d | ||
|
|
75429f288f | ||
|
|
d1bb921346 | ||
|
|
b979b6ddad | ||
|
|
4eba41ddbb | ||
|
|
418f5062ff | ||
|
|
f736f7f909 | ||
|
|
96ead28246 | ||
|
|
34bad7a53d | ||
|
|
7ac1fff3a0 | ||
|
|
a4c5c53df3 | ||
|
|
87db5cfd94 | ||
|
|
85e7bbf366 | ||
|
|
c55c5fac23 | ||
|
|
e25e2f7211 | ||
|
|
f310d583d8 | ||
|
|
f05465b29b | ||
|
|
959e31972e | ||
|
|
17181811f0 | ||
|
|
6d2624d52b | ||
|
|
9dd5940c8c | ||
|
|
1927d19961 | ||
|
|
09cc838bb4 | ||
|
|
8af4c71101 | ||
|
|
7ffdf45164 | ||
|
|
e0999dc9ae | ||
|
|
a0f3d44e97 | ||
|
|
1510a86579 | ||
|
|
b3581455d2 | ||
|
|
8ee1019fa5 | ||
|
|
285b10a95f | ||
|
|
0ca33f864b | ||
|
|
a0823fa26c | ||
|
|
aa9040da5d | ||
|
|
222031ecc5 | ||
|
|
dda8f5a974 | ||
|
|
e9b484df04 | ||
|
|
d505264e86 | ||
|
|
c0b1f1dc0a | ||
|
|
1524d558a4 | ||
|
|
aea8c11dc4 | ||
|
|
86c7f89788 | ||
|
|
3272541e81 | ||
|
|
3b3d40e4e6 | ||
|
|
a47866b6f7 | ||
|
|
0df4dfdef5 | ||
|
|
fe2de6ecf7 | ||
|
|
fc25e73b1a | ||
|
|
a3df85c87e |
8
.github/dependabot.yml
vendored
Normal 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
@@ -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
@@ -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
@@ -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
@@ -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 }}
|
||||
64
.github/workflows/release.yml
vendored
Normal 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
@@ -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
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win-classic\\.zip",
|
||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-win-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
@@ -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
@@ -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"]
|
||||
@@ -8,7 +8,6 @@
|
||||
# Advanced: Table of Contents
|
||||
|
||||
- [Files and folders](#files-and-folders)
|
||||
- [Linux and Mac (unofficial)](#linux-and-mac)
|
||||
- [Settings](#settings)
|
||||
- [Custom File Naming](#custom-file-naming)
|
||||
- [Command Line Interface](#command-line-interface)
|
||||
@@ -25,10 +24,6 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
||||
|
||||
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
|
||||
|
||||
### Linux and Mac
|
||||
|
||||
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
|
||||
|
||||
### Settings
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
@@ -76,4 +71,15 @@ export library to file
|
||||
libationcli export -p "C:\foo\bar\my.csv" -c
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
|
||||
libationcli export -p "C:\foo\bar\my.xlsx" -x
|
||||
|
||||
Set download statuses throughout library based on whether each book's audio file can be found.
|
||||
Must include at least one flag: --downloaded , --not-downloaded.
|
||||
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
|
||||
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
|
||||
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
|
||||
CLI: Full library. No prompt
|
||||
|
||||
libationcli set-status -d
|
||||
libationcli set-status -n
|
||||
libationcli set-status -d -n
|
||||
```
|
||||
|
||||
@@ -22,7 +22,12 @@
|
||||
|
||||
### Installation
|
||||
|
||||
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
|
||||
* Windows
|
||||
|
||||
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)
|
||||
|
||||
### Create Accounts
|
||||
|
||||
|
||||
@@ -1,4 +1,11 @@
|
||||
# Run Libation on Ubuntu
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Run Libation on Ubuntu (Beta)
|
||||
This walkthrough should get you up and running with Libation on your Ubuntu machine.
|
||||
|
||||
Some limitations of the linux release are:
|
||||
40
Documentation/InstallOnMac.md
Normal file
@@ -0,0 +1,40 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
|
||||
# Run Libation on MacOS (Beta)
|
||||
This walkthrough should get you up and running with Libation on your Mac.
|
||||
|
||||
## Install Libation
|
||||
|
||||
- Download latest MacOS zip to downloads folder
|
||||
- Extract and rename folder to Libation
|
||||
- in terminal type cd and then drag your folder of libation to terminal so it looks like `cd/users/YourName/Downloads/Libation`
|
||||
- Type following commands
|
||||
|
||||
```console
|
||||
chmod +x ./Libation
|
||||
sudo spctl --add --label "Libation" ./Libation
|
||||
./Libation
|
||||
```
|
||||
|
||||
## Trouble with Gatekeeper?
|
||||
|
||||
If Gatekeeper is giving you trouble with Libation:
|
||||
|
||||
Disable the block
|
||||
|
||||
`sudo spctl --master-disable`
|
||||
|
||||
Launch Libation and login, etc. and allow the rules to update then re-enable the block.
|
||||
|
||||
`sudo spctl --master-enable`
|
||||
|
||||
Once Gatekeeper reenabled, you can open Libation again without it being blocked.
|
||||
|
||||
Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)!
|
||||
|
||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||
@@ -14,7 +14,6 @@
|
||||
- [Getting started](Documentation/GettingStarted.md)
|
||||
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
|
||||
- [Installation](Documentation/GettingStarted.md#installation)
|
||||
- [Installation on Ubuntu](Source/LibationAvalonia/README.md)
|
||||
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
|
||||
- [Import your library](Documentation/GettingStarted.md#import-your-library)
|
||||
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
|
||||
@@ -28,7 +27,6 @@
|
||||
- [Filters](Documentation/SearchingAndFiltering.md#filters)
|
||||
- [Advanced](Documentation/Advanced.md)
|
||||
- [Files and folders](Documentation/Advanced.md#files-and-folders)
|
||||
- [Linux and Mac (unofficial)](Documentation/Advanced.md#linux-and-mac)
|
||||
- [Settings](Documentation/Advanced.md#settings)
|
||||
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
|
||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||
@@ -55,7 +53,7 @@
|
||||
|
||||
### The bad
|
||||
|
||||
* Windows only
|
||||
* Only fully supported in Windows. (Mac and Linux are in beta)
|
||||
* Large file size
|
||||
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.12" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.2.14" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core;
|
||||
@@ -52,7 +51,7 @@ namespace AaxDecrypter
|
||||
|
||||
// delete file after validation is complete
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
}
|
||||
}
|
||||
|
||||
public abstract Task CancelAsync();
|
||||
|
||||
@@ -72,7 +71,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 +110,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");
|
||||
@@ -156,12 +155,7 @@ namespace AaxDecrypter
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,91 +1,54 @@
|
||||
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;
|
||||
|
||||
#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
|
||||
|
||||
@@ -102,15 +65,12 @@ namespace AaxDecrypter
|
||||
|
||||
#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 +82,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 +98,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 +112,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 +120,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 +150,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;
|
||||
@@ -231,26 +171,23 @@ namespace AaxDecrypter
|
||||
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();
|
||||
}
|
||||
|
||||
} 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}).");
|
||||
@@ -264,8 +201,10 @@ namespace AaxDecrypter
|
||||
}
|
||||
finally
|
||||
{
|
||||
downloadedPiece.Set();
|
||||
downloadEnded.Set();
|
||||
networkStream.Close();
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
Update();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -274,96 +213,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 +233,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
get
|
||||
{
|
||||
if (!hasBegunDownloading)
|
||||
BeginDownloading();
|
||||
_backgroundDownloadTask ??= BeginDownloading();
|
||||
return ContentLength;
|
||||
}
|
||||
}
|
||||
@@ -401,18 +250,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 +276,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<Version>8.3.4.1</Version>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>8.7.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="1.0.0" />
|
||||
<PackageReference Include="Octokit" Version="4.0.3" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
|
||||
@@ -5,6 +5,7 @@ using System.Linq;
|
||||
using System.Reflection;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Logging;
|
||||
using LibationFileManager;
|
||||
@@ -23,19 +24,16 @@ namespace AppScaffolding
|
||||
MacOSAvalonia
|
||||
}
|
||||
|
||||
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
|
||||
public enum VarietyType { None, Classic, Chardonnay }
|
||||
|
||||
public static class LibationScaffolding
|
||||
{
|
||||
public static readonly bool IsWindows;
|
||||
public static readonly bool IsLinux;
|
||||
public static readonly bool IsMacOs;
|
||||
static LibationScaffolding()
|
||||
{
|
||||
IsWindows = OperatingSystem.IsWindows();
|
||||
IsLinux = OperatingSystem.IsLinux();
|
||||
IsMacOs = OperatingSystem.IsMacOS();
|
||||
}
|
||||
|
||||
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
|
||||
public static VarietyType Variety
|
||||
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
|
||||
: ReleaseIdentifier.In(ReleaseIdentifier.WindowsAvalonia, ReleaseIdentifier.LinuxAvalonia, ReleaseIdentifier.MacOSAvalonia) ? VarietyType.Chardonnay
|
||||
: VarietyType.None;
|
||||
|
||||
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
|
||||
=> ReleaseIdentifier = releaseID;
|
||||
@@ -57,8 +55,8 @@ namespace AppScaffolding
|
||||
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
|
||||
.Max(a => a.Version);
|
||||
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
|
||||
public static Configuration RunPreConfigMigrations()
|
||||
{
|
||||
// must occur before access to Configuration instance
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
@@ -90,10 +88,13 @@ namespace AppScaffolding
|
||||
{
|
||||
config.InProgress ??= Configuration.WinTemp;
|
||||
|
||||
if (!config.Exists(nameof(config.BetaOptIn)))
|
||||
config.BetaOptIn = false;
|
||||
if (!config.Exists(nameof(config.UseCoverAsFolderIcon)))
|
||||
config.UseCoverAsFolderIcon = false;
|
||||
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
if (!config.Exists(nameof(config.BetaOptIn)))
|
||||
config.BetaOptIn = false;
|
||||
|
||||
if (!config.Exists(nameof(config.AllowLibationFixup)))
|
||||
config.AllowLibationFixup = true;
|
||||
|
||||
if (!config.Exists(nameof(config.CreateCueSheet)))
|
||||
@@ -185,6 +186,8 @@ namespace AppScaffolding
|
||||
configureLogging(config);
|
||||
logStartupState(config);
|
||||
|
||||
// all else should occur after logging
|
||||
|
||||
wireUpSystemEvents(config);
|
||||
}
|
||||
|
||||
@@ -242,8 +245,8 @@ namespace AppScaffolding
|
||||
// However, empirical testing so far has shown no issues.
|
||||
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
|
||||
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
#region Console => Serilog tests
|
||||
/*
|
||||
// all below apply to "Console." and "Console.Out."
|
||||
|
||||
// captured
|
||||
@@ -282,12 +285,12 @@ namespace AppScaffolding
|
||||
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
|
||||
Console.Write("{0}", new object[] { "arr" });
|
||||
*/
|
||||
#endregion
|
||||
#endregion
|
||||
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
|
||||
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
|
||||
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
@@ -299,21 +302,16 @@ namespace AppScaffolding
|
||||
if (System.Diagnostics.Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
|
||||
string OS
|
||||
= IsLinux ? "Linux"
|
||||
: IsMacOs ? "MacOS"
|
||||
: IsWindows ? "Windows"
|
||||
: "UNKNOWN_OS";
|
||||
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly.GetName().Name,
|
||||
Version = BuildVersion.ToString(),
|
||||
ReleaseIdentifier = ReleaseIdentifier,
|
||||
OS = OS,
|
||||
Mode = mode,
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
@@ -321,8 +319,9 @@ namespace AppScaffolding
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.BetaOptIn,
|
||||
config.LibationFiles,
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
@@ -333,9 +332,12 @@ namespace AppScaffolding
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
|
||||
});
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
|
||||
|
||||
29
Source/AppScaffolding/OSConfigBase.cs
Normal file
@@ -0,0 +1,29 @@
|
||||
using System;
|
||||
|
||||
namespace AppScaffolding
|
||||
{
|
||||
public abstract class OSConfigBase
|
||||
{
|
||||
public abstract Type InteropFunctionsType { get; }
|
||||
public virtual Type[] ReferencedTypes { get; } = new Type[0];
|
||||
|
||||
public void Run()
|
||||
{
|
||||
//Each of these types belongs to a different windows-only assembly that's needed by
|
||||
//the WinInterop methods. By referencing these types in main we force the runtime to
|
||||
//load their assemblies before execution reaches inside main. This allows the calling
|
||||
//process to find these assemblies in its module list.
|
||||
_ = ReferencedTypes;
|
||||
_ = InteropFunctionsType;
|
||||
|
||||
//Wait for the calling process to be ready to read the WriteLine()
|
||||
Console.ReadLine();
|
||||
|
||||
// Signal the calling process that execution has reached inside main, and that all referenced assemblies have been loaded.
|
||||
Console.WriteLine();
|
||||
|
||||
// Wait for the calling process to finish reading the process module list, then exit.
|
||||
Console.ReadLine();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,7 +25,7 @@ namespace AppScaffolding
|
||||
: value;
|
||||
|
||||
#region appsettings.json
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Diagnostics.Process.GetCurrentProcess().MainModule.FileName), "appsettings.json");
|
||||
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(Environment.ProcessPath), "appsettings.json");
|
||||
|
||||
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
|
||||
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="CsvHelper" Version="28.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.5.6" />
|
||||
<PackageReference Include="CsvHelper" Version="30.0.1" />
|
||||
<PackageReference Include="NPOI" Version="2.6.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
78
Source/ApplicationServices/BulkSetDownloadStatus.cs
Normal file
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace ApplicationServices
|
||||
{
|
||||
public class BulkSetDownloadStatus
|
||||
{
|
||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
||||
|
||||
public int Count => actionSets.Count;
|
||||
|
||||
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
|
||||
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
|
||||
|
||||
private List<LibraryBook> _libraryBooks;
|
||||
private bool _setDownloaded;
|
||||
private bool _setNotDownloaded;
|
||||
|
||||
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
|
||||
{
|
||||
_libraryBooks = libraryBooks;
|
||||
_setDownloaded = setDownloaded;
|
||||
_setNotDownloaded = setNotDownloaded;
|
||||
}
|
||||
|
||||
public int Discover()
|
||||
{
|
||||
var bookExistsList = _libraryBooks
|
||||
.Select(libraryBook => new
|
||||
{
|
||||
libraryBook.Book,
|
||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||
})
|
||||
.ToList();
|
||||
|
||||
if (_setDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.Book)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
|
||||
LiberatedStatus.Liberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
if (_setNotDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.Book)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
actionSets.Add((
|
||||
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
|
||||
LiberatedStatus.NotLiberated,
|
||||
books2change));
|
||||
}
|
||||
|
||||
return Count;
|
||||
}
|
||||
|
||||
public void Execute()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
a.Books.UpdateBookStatus(a.newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -299,12 +299,12 @@ namespace ApplicationServices
|
||||
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
|
||||
}
|
||||
|
||||
var newCoutn = new LibraryBookImporter(context)
|
||||
var newCount = new LibraryBookImporter(context)
|
||||
.Import(newParentsImportItems);
|
||||
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
return newCoutn;
|
||||
return newCount;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -335,27 +335,72 @@ namespace ApplicationServices
|
||||
}
|
||||
#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)
|
||||
#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)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var libBooks = context.GetLibrary_Flat_NoTracking();
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
foreach (var lb in libraryBooks)
|
||||
{
|
||||
lb.IsDeleted = false;
|
||||
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -366,74 +411,101 @@ namespace ApplicationServices
|
||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
{
|
||||
book.UserDefinedItem.BookStatus = bookStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||
{
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus;
|
||||
return UpdateUserDefinedItem(book);
|
||||
}
|
||||
public static int UpdateBook(
|
||||
public static int UpdateUserDefinedItem(
|
||||
this Book book,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null)
|
||||
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
|
||||
public static int UpdateBooks(
|
||||
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus);
|
||||
|
||||
public static int UpdateUserDefinedItem(
|
||||
this IEnumerable<Book> books,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
params Book[] books)
|
||||
LiberatedStatus? pdfStatus = null)
|
||||
=> updateUserDefinedItem(
|
||||
books,
|
||||
udi => {
|
||||
// blank tags are expected. null tags are not
|
||||
if (tags is not null && udi.Tags != tags)
|
||||
udi.Tags = tags;
|
||||
|
||||
if (bookStatus is not null && udi.BookStatus != bookStatus.Value)
|
||||
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;
|
||||
});
|
||||
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
|
||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
||||
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
||||
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
||||
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.PdfStatus = pdfStatus);
|
||||
|
||||
public static int UpdateTags(this Book book, string tags)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this IEnumerable<Book> books, string tags)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
|
||||
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||
=> libraryBook.Book.updateUserDefinedItem(action);
|
||||
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> 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);
|
||||
|
||||
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
|
||||
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
|
||||
{
|
||||
foreach (var book in books)
|
||||
{
|
||||
// blank tags are expected. null tags are not
|
||||
if (tags is not null && book.UserDefinedItem.Tags != tags)
|
||||
book.UserDefinedItem.Tags = tags;
|
||||
try
|
||||
{
|
||||
if (books is null || !books.Any())
|
||||
return 0;
|
||||
|
||||
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
|
||||
book.UserDefinedItem.BookStatus = bookStatus.Value;
|
||||
|
||||
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
|
||||
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
|
||||
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
|
||||
}
|
||||
|
||||
return UpdateUserDefinedItem(books);
|
||||
}
|
||||
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
|
||||
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (books is null || !books.Any())
|
||||
return 0;
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
foreach (var book in books)
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
action?.Invoke(book.UserDefinedItem);
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
foreach (var book in books)
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
|
||||
// must be here instead of in db layer due to AaxcExists
|
||||
public static LiberatedStatus Liberated_Status(Book book)
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
|
||||
throw;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
// 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;
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="4.6.0.1" />
|
||||
<PackageReference Include="AudibleApi" Version="7.1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
@@ -10,14 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="5.0.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.7">
|
||||
<PackageReference Include="Dinah.Core" Version="7.0.0.2" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.0.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.7">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.0">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
401
Source/DataLayer/Migrations/20221214205106_LibraryBookIsDeleted.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -84,7 +84,8 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
var authors = item
|
||||
.Authors
|
||||
.Select(a => contributorImporter.Cache[a.Name])
|
||||
.DistinctBy(a => a.Name)
|
||||
.Select(a => contributorImporter.Cache[a.Name])
|
||||
.ToList();
|
||||
|
||||
var narrators
|
||||
@@ -94,7 +95,8 @@ namespace DtoImporterService
|
||||
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
|
||||
: item
|
||||
.Narrators
|
||||
.Select(n => contributorImporter.Cache[n.Name])
|
||||
.DistinctBy(a => a.Name)
|
||||
.Select(n => contributorImporter.Cache[n.Name])
|
||||
.ToList();
|
||||
|
||||
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -14,185 +14,188 @@ using LibationFileManager;
|
||||
|
||||
namespace FileLiberator
|
||||
{
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
public class DownloadDecryptBook : AudioDecodable
|
||||
{
|
||||
public override string Name => "Download & Decrypt";
|
||||
private AudiobookDownloadBase abDownloader;
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
|
||||
|
||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// in case of parallel decrypts, only capture the ones for this book id.
|
||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Add(e);
|
||||
}
|
||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Remove(e);
|
||||
}
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var entries = new List<FilePathCache.CacheEntry>();
|
||||
// these only work so minimally b/c CacheEntry is a record.
|
||||
// in case of parallel decrypts, only capture the ones for this book id.
|
||||
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
|
||||
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Add(e);
|
||||
}
|
||||
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
|
||||
{
|
||||
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
|
||||
entries.Remove(e);
|
||||
}
|
||||
|
||||
OnBegin(libraryBook);
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
bool success = false;
|
||||
try
|
||||
{
|
||||
FilePathCache.Inserted += FilePathCache_Inserted;
|
||||
FilePathCache.Removed += FilePathCache_Removed;
|
||||
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
success = await downloadAudiobookAsync(libraryBook);
|
||||
}
|
||||
finally
|
||||
{
|
||||
FilePathCache.Inserted -= FilePathCache_Inserted;
|
||||
FilePathCache.Removed -= FilePathCache_Removed;
|
||||
}
|
||||
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
{
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
// decrypt failed
|
||||
if (!success)
|
||||
{
|
||||
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
|
||||
FileUtility.SaferDelete(tmpFile.Path);
|
||||
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
return abDownloader?.IsCanceled == true ?
|
||||
new StatusHandler { "Cancelled" } :
|
||||
new StatusHandler { "Decrypt failed" };
|
||||
}
|
||||
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
// moves new files from temp dir to final dest.
|
||||
// This could take a few seconds if moving hundreds of files.
|
||||
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
|
||||
// decrypt failed
|
||||
if (!movedAudioFile)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
// decrypt failed
|
||||
if (finalStorageDir is null)
|
||||
return new StatusHandler { "Cannot find final audio file after decryption" };
|
||||
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
DownloadCoverArt(libraryBook);
|
||||
if (Configuration.Instance.DownloadCoverArt)
|
||||
downloadCoverArt(libraryBook);
|
||||
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
// contains logic to check for config setting and OS
|
||||
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var config = Configuration.Instance;
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
|
||||
{
|
||||
var config = Configuration.Instance;
|
||||
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
|
||||
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||
else
|
||||
{
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ?
|
||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
|
||||
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
|
||||
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
|
||||
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
|
||||
else
|
||||
{
|
||||
AaxcDownloadConvertBase converter
|
||||
= config.SplitFilesByChapter ?
|
||||
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
|
||||
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
|
||||
|
||||
abDownloader = converter;
|
||||
}
|
||||
if (config.AllowLibationFixup)
|
||||
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
|
||||
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
abDownloader = converter;
|
||||
}
|
||||
|
||||
// REAL WORK DONE HERE
|
||||
var success = await abDownloader.RunAsync();
|
||||
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
|
||||
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
|
||||
abDownloader.RetrievedTitle += OnTitleDiscovered;
|
||||
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
|
||||
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
|
||||
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
|
||||
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
|
||||
|
||||
return success;
|
||||
}
|
||||
// REAL WORK DONE HERE
|
||||
var success = await abDownloader.RunAsync();
|
||||
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
{
|
||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
||||
return success;
|
||||
}
|
||||
|
||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
{
|
||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
||||
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||
|
||||
long chapterStartMs = config.StripAudibleBrandAudio ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
|
||||
var dlOptions = new DownloadOptions
|
||||
(
|
||||
libraryBook,
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
{
|
||||
AudibleKey = contentLic?.Voucher?.Key,
|
||||
AudibleIV = contentLic?.Voucher?.Iv,
|
||||
OutputFormat = outputFormat,
|
||||
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
||||
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
||||
CreateCueSheet = config.CreateCueSheet,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
FixupFile = config.AllowLibationFixup
|
||||
};
|
||||
long chapterStartMs = config.StripAudibleBrandAudio ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
|
||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||
var dlOptions = new DownloadOptions
|
||||
(
|
||||
libraryBook,
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
{
|
||||
AudibleKey = contentLic?.Voucher?.Key,
|
||||
AudibleIV = contentLic?.Voucher?.Iv,
|
||||
OutputFormat = outputFormat,
|
||||
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
||||
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
||||
CreateCueSheet = config.CreateCueSheet,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
FixupFile = config.AllowLibationFixup
|
||||
};
|
||||
|
||||
if (config.MergeOpeningAndEndCredits)
|
||||
combineCredits(chapters);
|
||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
if (config.MergeOpeningAndEndCredits)
|
||||
combineCredits(chapters);
|
||||
|
||||
if (i == 0)
|
||||
chapLenMs -= chapterStartMs;
|
||||
for (int i = 0; i < chapters.Count; i++)
|
||||
{
|
||||
var chapter = chapters[i];
|
||||
long chapLenMs = chapter.LengthMs;
|
||||
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
if (i == 0)
|
||||
chapLenMs -= chapterStartMs;
|
||||
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
|
||||
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
|
||||
|
||||
return dlOptions;
|
||||
}
|
||||
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
|
||||
}
|
||||
|
||||
/*
|
||||
return dlOptions;
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
Flatten Audible's new hierarchical chapters, combining children into parents.
|
||||
|
||||
@@ -267,143 +270,138 @@ namespace FileLiberator
|
||||
|
||||
*/
|
||||
|
||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||
{
|
||||
List<AudibleApi.Common.Chapter> chaps = new();
|
||||
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
|
||||
{
|
||||
List<AudibleApi.Common.Chapter> chaps = new();
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is not null)
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||
c.Chapters[0].LengthMs += c.LengthMs;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is not null)
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
|
||||
c.Chapters[0].LengthMs += c.LengthMs;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
|
||||
var children = flattenChapters(c.Chapters);
|
||||
var children = flattenChapters(c.Chapters);
|
||||
|
||||
foreach (var child in children)
|
||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||
foreach (var child in children)
|
||||
child.Title = $"{c.Title}{titleConcat}{child.Title}";
|
||||
|
||||
chaps.AddRange(children);
|
||||
c.Chapters = null;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
chaps.AddRange(children);
|
||||
c.Chapters = null;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||
{
|
||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||
{
|
||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||
chapters.RemoveAt(0);
|
||||
}
|
||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||
{
|
||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||
chapters.Remove(chapters[^1]);
|
||||
}
|
||||
}
|
||||
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
|
||||
{
|
||||
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
|
||||
{
|
||||
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
|
||||
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
|
||||
chapters[1].LengthMs += chapters[0].LengthMs;
|
||||
chapters.RemoveAt(0);
|
||||
}
|
||||
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
|
||||
{
|
||||
chapters[^2].LengthMs += chapters[^1].LengthMs;
|
||||
chapters.Remove(chapters[^1]);
|
||||
}
|
||||
}
|
||||
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
private static void downloadValidation(LibraryBook libraryBook)
|
||||
{
|
||||
string errorString(string field)
|
||||
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
|
||||
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Account))
|
||||
throw new Exception(errorString("Account"));
|
||||
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
|
||||
throw new Exception(errorString("Locale"));
|
||||
}
|
||||
|
||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||
}
|
||||
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
|
||||
{
|
||||
if (e is not null)
|
||||
OnCoverImageDiscovered(e);
|
||||
else if (Configuration.Instance.AllowLibationFixup)
|
||||
abDownloader.SetCoverArt(OnRequestCoverArt());
|
||||
}
|
||||
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
|
||||
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
/// <summary>Move new files to 'Books' directory</summary>
|
||||
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
|
||||
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
|
||||
{
|
||||
// create final directory. move each file into it
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
Directory.CreateDirectory(destinationDir);
|
||||
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
|
||||
|
||||
if (getFirstAudio() == default)
|
||||
return false;
|
||||
if (getFirstAudio() == default)
|
||||
return null;
|
||||
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
for (var i = 0; i < entries.Count; i++)
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
}
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
|
||||
return true;
|
||||
}
|
||||
return destinationDir;
|
||||
}
|
||||
|
||||
private void DownloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
var coverPath = "[null]";
|
||||
|
||||
try
|
||||
{
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
try
|
||||
{
|
||||
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
|
||||
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
|
||||
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
|
||||
|
||||
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
|
||||
(libraryBook.Book.PictureId, PictureSize.Native) :
|
||||
(libraryBook.Book.PictureLarge, PictureSize.Native);
|
||||
if (File.Exists(coverPath))
|
||||
FileUtility.SaferDelete(coverPath);
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
|
||||
|
||||
if (picBytes.Length > 0)
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be
|
||||
//considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||
if (picBytes.Length > 0)
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Failure to download cover art should not be considered a failure to download the book
|
||||
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ namespace FileLiberator
|
||||
{
|
||||
public class DownloadOptions : IDownloadOptions
|
||||
{
|
||||
public LibraryBook LibraryBook { get; }
|
||||
public LibraryBookDto LibraryBookDto { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public string UserAgent { get; }
|
||||
@@ -35,12 +34,12 @@ namespace FileLiberator
|
||||
|
||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
||||
{
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
LibraryBookDto = ArgumentValidator
|
||||
.EnsureNotNull(libraryBook, nameof(libraryBook))
|
||||
.ToDto();
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
|
||||
LibraryBookDto = LibraryBook.ToDto();
|
||||
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ namespace FileLiberator
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
try
|
||||
{
|
||||
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
@@ -32,13 +32,22 @@ namespace FileLiberator
|
||||
|
||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
|
||||
|
||||
var result = new StatusHandler();
|
||||
result.AddError($"Error downloading PDF. See log for details. Error summary: {ex.Message}");
|
||||
|
||||
return result;
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
|
||||
{
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -31,6 +31,7 @@ namespace FileLiberator
|
||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||
Title = libraryBook.Book.Title ?? "",
|
||||
Locale = libraryBook.Book.Locale,
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
|
||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="7.0.0.2" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -52,7 +52,7 @@ namespace FileManager
|
||||
return FileUtility.GetValidFilename(Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - 5)), replacements, returnFirstExisting);
|
||||
}
|
||||
|
||||
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
||||
private static string replaceFileName(string filename, Dictionary<string,string> paramReplacements, int maxFilenameLength)
|
||||
{
|
||||
List<StringBuilder> filenameParts = new();
|
||||
//Build the filename in parts, replacing replacement parameters with
|
||||
@@ -98,14 +98,14 @@ namespace FileManager
|
||||
return string.Join("", filenameParts);
|
||||
}
|
||||
|
||||
private string formatValue(object value, ReplacementCharacters replacements)
|
||||
private static string formatValue(object value, ReplacementCharacters replacements)
|
||||
{
|
||||
if (value is null)
|
||||
return "";
|
||||
|
||||
// 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ namespace FileManager
|
||||
|
||||
var pathNoPrefix = path.PathWithoutPrefix;
|
||||
|
||||
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
|
||||
pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix);
|
||||
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
||||
|
||||
return pathNoPrefix;
|
||||
@@ -212,25 +212,23 @@ namespace FileManager
|
||||
{
|
||||
var foundFiles = Enumerable.Empty<LongPath>();
|
||||
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
try
|
||||
{
|
||||
try
|
||||
if (searchOption == SearchOption.AllDirectories)
|
||||
{
|
||||
IEnumerable <LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
||||
IEnumerable<LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
|
||||
// Add files in subdirectories recursively to the list
|
||||
foreach (string dir in subDirs)
|
||||
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
// Add files from the current directory
|
||||
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (PathTooLongException) { }
|
||||
// Symbolic links will result in DirectoryNotFoundException. Ohter logical directories might also. Just skip them. Don't want to risk (or have to handle) infinite recursion
|
||||
catch (DirectoryNotFoundException) { }
|
||||
|
||||
return foundFiles;
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace FileManager
|
||||
public const int MaxFilenameLength = 255;
|
||||
|
||||
private const int MAX_PATH = 260;
|
||||
private const string LONG_PATH_PREFIX = "\\\\?\\";
|
||||
private const string LONG_PATH_PREFIX = @"\\?\";
|
||||
|
||||
public string Path { get; init; }
|
||||
public override string ToString() => Path;
|
||||
@@ -35,13 +35,13 @@ namespace FileManager
|
||||
|
||||
if (path.StartsWith(LONG_PATH_PREFIX))
|
||||
return new LongPath { Path = path };
|
||||
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
|
||||
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith(@"UNC\"))
|
||||
return new LongPath { Path = LONG_PATH_PREFIX + path };
|
||||
else if (path.StartsWith("\\\\"))
|
||||
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 { Path = LONG_PATH_PREFIX + @"UNC\" + path.Substring(2) };
|
||||
else
|
||||
{
|
||||
//These prefixes are not used as part of the path itself. They indicate that
|
||||
|
||||
@@ -179,6 +179,7 @@ namespace FileManager
|
||||
}
|
||||
catch (Exception exDebug)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,10 @@ namespace FileManager
|
||||
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)
|
||||
@@ -61,59 +61,108 @@ namespace FileManager
|
||||
[JsonConverter(typeof(ReplacementCharactersConverter))]
|
||||
public class ReplacementCharacters
|
||||
{
|
||||
public static readonly ReplacementCharacters Default = new()
|
||||
static ReplacementCharacters()
|
||||
{
|
||||
Replacements = new List<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 LoFiDefault = new()
|
||||
{
|
||||
Replacements = new List<Replacement>()
|
||||
}
|
||||
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 +207,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 +220,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 +233,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 +243,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 +300,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 +322,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));
|
||||
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace Hangover
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private string dbFile;
|
||||
|
||||
private void Load_databaseTab()
|
||||
{
|
||||
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
if (dbFile is null)
|
||||
{
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
return;
|
||||
}
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
ensureBackup();
|
||||
|
||||
sqlResultsTb.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
var sql = sqlTb.Text.Trim();
|
||||
|
||||
#region // explanation
|
||||
// Routing statements to non-query is a convenience.
|
||||
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
|
||||
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
|
||||
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
|
||||
// -- line 1 is a comment
|
||||
// delete from foo
|
||||
#endregion
|
||||
var lower = sql.ToLower();
|
||||
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
||||
nonQuery(sql);
|
||||
else
|
||||
query(sql);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
sqlResultsTb.Text = $"{ex.Message}\r\n{ex.StackTrace}";
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
|
||||
private string dbBackup;
|
||||
private DateTime dbFileLastModified;
|
||||
private void ensureBackup()
|
||||
{
|
||||
if (dbBackup is not null)
|
||||
return;
|
||||
|
||||
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
|
||||
dbBackup
|
||||
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
|
||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||
+ Path.GetExtension(dbFile);
|
||||
File.Copy(dbFile, dbBackup);
|
||||
}
|
||||
|
||||
private void deleteUnneededBackups()
|
||||
{
|
||||
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
if (dbFileLastModified == newLastModified)
|
||||
{
|
||||
File.Delete(dbBackup);
|
||||
dbBackup = null;
|
||||
}
|
||||
}
|
||||
|
||||
void query(string sql)
|
||||
{
|
||||
// ef doesn't support truly generic queries. have to drop down to ado.net
|
||||
using var context = DbContexts.GetContext();
|
||||
using var conn = context.Database.GetDbConnection();
|
||||
conn.Open();
|
||||
using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = sql;
|
||||
|
||||
var reader = cmd.ExecuteReader();
|
||||
var results = 0;
|
||||
var builder = new System.Text.StringBuilder();
|
||||
var lines = 0;
|
||||
while (reader.Read())
|
||||
{
|
||||
results++;
|
||||
|
||||
for (var i = 0; i < reader.FieldCount; i++)
|
||||
builder.Append(reader.GetValue(i) + "\t");
|
||||
builder.AppendLine();
|
||||
|
||||
lines++;
|
||||
if (lines % 10 == 0)
|
||||
{
|
||||
sqlResultsTb.AppendText(builder.ToString());
|
||||
builder.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
sqlResultsTb.AppendText(builder.ToString());
|
||||
builder.Clear();
|
||||
|
||||
if (results == 0)
|
||||
sqlResultsTb.Text = "[no results]";
|
||||
else
|
||||
{
|
||||
sqlResultsTb.AppendText($"\r\n{results} result");
|
||||
if (results != 1) sqlResultsTb.AppendText("s");
|
||||
}
|
||||
}
|
||||
|
||||
void nonQuery(string sql)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var results = context.Database.ExecuteSqlRaw(sql);
|
||||
|
||||
sqlResultsTb.AppendText($"{results} record");
|
||||
if (results != 1) sqlResultsTb.AppendText("s");
|
||||
sqlResultsTb.AppendText(" affected");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,16 +0,0 @@
|
||||
namespace Hangover
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
30
Source/HangoverAvalonia/Controls/CheckedListBox.axaml
Normal 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>
|
||||
104
Source/HangoverAvalonia/Controls/CheckedListBox.axaml.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
|
||||
<TrimMode>copyused</TrimMode>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
@@ -20,6 +20,16 @@
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup>
|
||||
<!--
|
||||
HACK FOR COMPILER BUG 2021-09-14. Hopefully will be fixed in future versions
|
||||
- Not using SatelliteResourceLanguages will load all language packs: works
|
||||
- Specifying 'en' semicolon 1 more should load 1 language pack: works
|
||||
- Specifying only 'en' should load no language packs: broken, still loads all
|
||||
-->
|
||||
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
@@ -40,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" />
|
||||
@@ -53,17 +71,17 @@
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.17" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.17" />
|
||||
|
||||
<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.17" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.17" />
|
||||
<PackageReference Include="XamlNameReferenceGenerator" Version="1.3.4" />
|
||||
<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="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.ReactiveUI;
|
||||
using System;
|
||||
|
||||
|
||||
@@ -3,15 +3,15 @@
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Release\linux-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<RuntimeIdentifier>linux-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -6,12 +6,12 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Release\macos-chardonnay</PublishDir>
|
||||
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<RuntimeIdentifier>osx-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
@@ -6,9 +6,9 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Release\win-chardonnay</PublishDir>
|
||||
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
|
||||
36
Source/HangoverAvalonia/ViewModels/MainVM.Database.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
41
Source/HangoverAvalonia/ViewModels/MainVM.Deleted.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
11
Source/HangoverAvalonia/ViewModels/MainVM.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
{
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public MainVM()
|
||||
{
|
||||
Load_databaseVM();
|
||||
Load_deletedVM();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,4 @@
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
{
|
||||
|
||||
11
Source/HangoverAvalonia/Views/MainWindow.CLI.cs
Normal file
@@ -0,0 +1,11 @@
|
||||
namespace HangoverAvalonia.Views
|
||||
{
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void cliTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
16
Source/HangoverAvalonia/Views/MainWindow.Database.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
40
Source/HangoverAvalonia/Views/MainWindow.Deleted.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,49 +1,70 @@
|
||||
using System.Text;
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using Dinah.Core;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
namespace HangoverBase
|
||||
{
|
||||
public class MainWindowViewModel : ViewModelBase
|
||||
public class DatabaseTabCommands
|
||||
{
|
||||
private string dbFile;
|
||||
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 Func<string> SqlInput { get; }
|
||||
public Action<string> SqlOutputAppend { get; }
|
||||
public Action<string> SqlOutputOverwrite { get; }
|
||||
|
||||
public MainWindowViewModel()
|
||||
public DatabaseTabCommands() { }
|
||||
public DatabaseTabCommands(
|
||||
Func<string> sqlInput,
|
||||
Action<string> sqlDisplayAppend,
|
||||
Action<string> sqlDisplayOverwrite)
|
||||
{
|
||||
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
if (dbFile is null)
|
||||
{
|
||||
DatabaseFileText = $"Database file not found";
|
||||
DatabaseFound = false;
|
||||
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
|
||||
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
|
||||
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
|
||||
}
|
||||
}
|
||||
|
||||
public class DatabaseTab
|
||||
{
|
||||
private DatabaseTabCommands _commands { get; }
|
||||
public string DbFile { get; private set; }
|
||||
|
||||
public DatabaseTab(DatabaseTabCommands commands)
|
||||
{
|
||||
_commands = ArgumentValidator.EnsureNotNull(commands, nameof(commands));
|
||||
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
|
||||
}
|
||||
|
||||
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
|
||||
private string dbBackup;
|
||||
private DateTime dbFileLastModified;
|
||||
public void EnsureBackup()
|
||||
{
|
||||
if (dbBackup is not null)
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseFileText = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
|
||||
dbFileLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||
|
||||
DatabaseFound = UNSAFE_MigrationHelper.DatabaseFile is not null;
|
||||
dbBackup
|
||||
= Path.ChangeExtension(DbFile, "").TrimEnd('.')
|
||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||
+ Path.GetExtension(DbFile);
|
||||
File.Copy(DbFile, dbBackup);
|
||||
}
|
||||
|
||||
public void ExecuteQuery()
|
||||
{
|
||||
ensureBackup();
|
||||
EnsureBackup();
|
||||
|
||||
SqlResults = string.Empty;
|
||||
_commands.SqlOutputOverwrite("");
|
||||
|
||||
try
|
||||
{
|
||||
var sql = SqlQuery.Trim();
|
||||
var sql = _commands.SqlInput().Trim();
|
||||
|
||||
#region // explanation
|
||||
// Routing statements to non-query is a convenience.
|
||||
@@ -55,40 +76,23 @@ namespace HangoverAvalonia.ViewModels
|
||||
#endregion
|
||||
var lower = sql.ToLower();
|
||||
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
|
||||
nonQuery(sql);
|
||||
NonQuery(sql);
|
||||
else
|
||||
query(sql);
|
||||
Query(sql);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
SqlResults = $"{ex.Message}\r\n{ex.StackTrace}";
|
||||
_commands.SqlOutputOverwrite($"{ex.Message}\r\n{ex.StackTrace}");
|
||||
}
|
||||
finally
|
||||
{
|
||||
deleteUnneededBackups();
|
||||
DeleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
|
||||
private string dbBackup;
|
||||
private DateTime dbFileLastModified;
|
||||
|
||||
private void ensureBackup()
|
||||
public void DeleteUnneededBackups()
|
||||
{
|
||||
if (dbBackup is not null)
|
||||
return;
|
||||
|
||||
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
|
||||
dbBackup
|
||||
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
|
||||
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
|
||||
+ Path.GetExtension(dbFile);
|
||||
File.Copy(dbFile, dbBackup);
|
||||
}
|
||||
|
||||
private void deleteUnneededBackups()
|
||||
{
|
||||
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
|
||||
var newLastModified = File.GetLastWriteTimeUtc(DbFile);
|
||||
if (dbFileLastModified == newLastModified)
|
||||
{
|
||||
File.Delete(dbBackup);
|
||||
@@ -96,7 +100,7 @@ namespace HangoverAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
void query(string sql)
|
||||
public void Query(string sql)
|
||||
{
|
||||
// ef doesn't support truly generic queries. have to drop down to ado.net
|
||||
using var context = DbContexts.GetContext();
|
||||
@@ -120,31 +124,31 @@ namespace HangoverAvalonia.ViewModels
|
||||
lines++;
|
||||
if (lines % 10 == 0)
|
||||
{
|
||||
SqlResults += builder.ToString();
|
||||
_commands.SqlOutputAppend(builder.ToString());
|
||||
builder.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
SqlResults += builder.ToString();
|
||||
_commands.SqlOutputAppend(builder.ToString());
|
||||
builder.Clear();
|
||||
|
||||
if (results == 0)
|
||||
SqlResults = "[no results]";
|
||||
_commands.SqlOutputOverwrite("[no results]");
|
||||
else
|
||||
{
|
||||
SqlResults += $"\r\n{results} result";
|
||||
if (results != 1) SqlResults += "s";
|
||||
_commands.SqlOutputAppend($"\r\n{results} result");
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
}
|
||||
}
|
||||
|
||||
void nonQuery(string sql)
|
||||
public void NonQuery(string sql)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var results = context.Database.ExecuteSqlRaw(sql);
|
||||
|
||||
SqlResults += $"{results} record";
|
||||
if (results != 1) SqlResults += "s";
|
||||
SqlResults += " affected";
|
||||
_commands.SqlOutputAppend($"{results} record");
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
_commands.SqlOutputAppend(" affected");
|
||||
}
|
||||
}
|
||||
}
|
||||
20
Source/HangoverBase/HangoverBase.csproj
Normal file
@@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
</Project>
|
||||
@@ -1,6 +1,6 @@
|
||||
using AppScaffolding;
|
||||
|
||||
namespace Hangover
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
@@ -11,7 +11,7 @@ namespace Hangover
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
if (!cliTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
31
Source/HangoverWinForms/Form1.Database.cs
Normal file
@@ -0,0 +1,31 @@
|
||||
using HangoverBase;
|
||||
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1
|
||||
{
|
||||
private DatabaseTab _tab;
|
||||
|
||||
private void Load_databaseTab()
|
||||
{
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
return;
|
||||
}
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
return;
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
|
||||
}
|
||||
}
|
||||
73
Source/HangoverWinForms/Form1.Deleted.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hangover
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
partial class Form1
|
||||
{
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
24
Source/HangoverWinForms/Form1.cs
Normal file
@@ -0,0 +1,24 @@
|
||||
using AppScaffolding;
|
||||
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
public Form1()
|
||||
{
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net6.0-windows</TargetFramework>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
@@ -44,9 +45,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -55,7 +54,7 @@
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
<!-- Remove obj folder -->
|
||||
<RemoveDir Directories="$(BaseIntermediateOutputPath)" />
|
||||
<!-- Remove bin folder -->
|
||||
@@ -1,4 +1,4 @@
|
||||
namespace Hangover
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
internal static class Program
|
||||
{
|
||||
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<!--
|
||||
https://go.microsoft.com/fwlink/?LinkID=208121.
|
||||
-->
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<Configuration>Release</Configuration>
|
||||
<Platform>Any CPU</Platform>
|
||||
<PublishDir>..\bin\Publish\classic</PublishDir>
|
||||
<PublishProtocol>FileSystem</PublishProtocol>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<SelfContained>true</SelfContained>
|
||||
<PublishSingleFile>false</PublishSingleFile>
|
||||
<PublishTrimmed>false</PublishTrimmed>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
|
Before Width: | Height: | Size: 7.5 KiB After Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 498 B After Width: | Height: | Size: 498 B |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 1.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 50 KiB |
|
Before Width: | Height: | Size: 2.9 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 133 KiB After Width: | Height: | Size: 133 KiB |
@@ -7,9 +7,9 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solutio
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
REFERENCE.txt = REFERENCE.txt
|
||||
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
|
||||
_AvaloniaUI Primer.txt = _AvaloniaUI Primer.txt
|
||||
_DB_NOTES.txt = _DB_NOTES.txt
|
||||
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
|
||||
__TODO.txt = __TODO.txt
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
|
||||
@@ -64,12 +64,35 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangover", "Hangover\Hangover.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverWinForms", "HangoverWinForms\HangoverWinForms.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationAvalonia", "LibationAvalonia\LibationAvalonia.csproj", "{F612D06F-3134-4B9B-95CD-EB3FC798AE60}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverAvalonia", "HangoverAvalonia\HangoverAvalonia.csproj", "{8A7B01D3-9830-44FD-91A1-D8D010996BEB}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "HangoverBase", "HangoverBase\HangoverBase.csproj", "{5C7005BA-7D83-4E99-8073-D970943A7D61}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Demos", "_Demos", "{185AC9FF-381E-4AA1-B649-9771F4917214}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LoadByOS", "LoadByOS", "{59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "CrossPlatformClientExe", "_Demos\LoadByOS\CrossPlatformClientExe\CrossPlatformClientExe.csproj", "{CC275937-DFE4-4383-B1BF-1D5D42B70C98}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinuxConfigApp", "_Demos\LoadByOS\LinuxConfigApp\LinuxConfigApp.csproj", "{47325742-5B38-48E7-95FB-CD94E6E07332}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsConfigApp", "_Demos\LoadByOS\WindowsConfigApp\WindowsConfigApp.csproj", "{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}"
|
||||
EndProject
|
||||
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "LoadByOS", "LoadByOS", "{9B906374-1142-4D69-86FF-B384806CA5FE}"
|
||||
ProjectSection(SolutionItems) = preProject
|
||||
LoadByOS\README.txt = LoadByOS\README.txt
|
||||
EndProjectSection
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LinuxConfigApp", "LoadByOS\LinuxConfigApp\LinuxConfigApp.csproj", "{357DF797-4EC2-4DBD-A4BD-D045277F2666}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "MacOSConfigApp", "LoadByOS\MacOSConfigApp\MacOSConfigApp.csproj", "{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}"
|
||||
EndProject
|
||||
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsConfigApp", "LoadByOS\WindowsConfigApp\WindowsConfigApp.csproj", "{5F65A509-26E3-4B02-B403-EEB6F0EF391F}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -156,6 +179,34 @@ Global
|
||||
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{8A7B01D3-9830-44FD-91A1-D8D010996BEB}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5C7005BA-7D83-4E99-8073-D970943A7D61}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{CC275937-DFE4-4383-B1BF-1D5D42B70C98}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{47325742-5B38-48E7-95FB-CD94E6E07332}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{47325742-5B38-48E7-95FB-CD94E6E07332}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{47325742-5B38-48E7-95FB-CD94E6E07332}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{47325742-5B38-48E7-95FB-CD94E6E07332}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{357DF797-4EC2-4DBD-A4BD-D045277F2666}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{5F65A509-26E3-4B02-B403-EEB6F0EF391F}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(SolutionProperties) = preSolution
|
||||
HideSolutionNode = FALSE
|
||||
@@ -181,6 +232,15 @@ Global
|
||||
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{F612D06F-3134-4B9B-95CD-EB3FC798AE60} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{8A7B01D3-9830-44FD-91A1-D8D010996BEB} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{5C7005BA-7D83-4E99-8073-D970943A7D61} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F} = {185AC9FF-381E-4AA1-B649-9771F4917214}
|
||||
{CC275937-DFE4-4383-B1BF-1D5D42B70C98} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
|
||||
{47325742-5B38-48E7-95FB-CD94E6E07332} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
|
||||
{0520760A-9CFB-48A8-BCE4-6E951AFD6BE9} = {59DF46F3-ECD0-43CA-AD12-3FEE8FCF9E4F}
|
||||
{9B906374-1142-4D69-86FF-B384806CA5FE} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
|
||||
{357DF797-4EC2-4DBD-A4BD-D045277F2666} = {9B906374-1142-4D69-86FF-B384806CA5FE}
|
||||
{ECED4E13-B676-4277-8A8F-C8B2507B7D8C} = {9B906374-1142-4D69-86FF-B384806CA5FE}
|
||||
{5F65A509-26E3-4B02-B403-EEB6F0EF391F} = {9B906374-1142-4D69-86FF-B384806CA5FE}
|
||||
EndGlobalSection
|
||||
GlobalSection(ExtensibilityGlobals) = postSolution
|
||||
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -29,33 +29,6 @@ namespace LibationAvalonia
|
||||
public static Stream OpenAsset(string assetRelativePath)
|
||||
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
|
||||
|
||||
|
||||
public static bool GoToFile(string path)
|
||||
=> AppScaffolding.LibationScaffolding.IsWindows ? Go.To.File(path)
|
||||
: GoToFolder(path is null ? string.Empty : Path.GetDirectoryName(path));
|
||||
|
||||
public static bool GoToFolder(string path)
|
||||
{
|
||||
if (AppScaffolding.LibationScaffolding.IsWindows)
|
||||
return Go.To.Folder(path);
|
||||
else if (AppScaffolding.LibationScaffolding.IsLinux)
|
||||
{
|
||||
var startInfo = new System.Diagnostics.ProcessStartInfo()
|
||||
{
|
||||
FileName = "/bin/xdg-open",
|
||||
Arguments = path is null ? string.Empty : $"\"{path}\"",
|
||||
UseShellExecute = false, //Import in Linux environments
|
||||
CreateNoWindow = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true
|
||||
};
|
||||
System.Diagnostics.Process.Start(startInfo);
|
||||
return true;
|
||||
}
|
||||
//Don't know how to do this for mac yet
|
||||
else return true;
|
||||
}
|
||||
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -69,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)
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
<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" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="{StaticResource SystemAltHighColor}" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookBorderBrush" Color="Gray" />
|
||||
</Styles.Resources>
|
||||
<Style Selector="TextBox[IsReadOnly=true]">
|
||||
<Setter Property="Background" Value="LightGray" />
|
||||
<Setter Property="CaretBrush" Value="#00000000" />
|
||||
</Style>
|
||||
</Styles>
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<DataGridTemplateColumn 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"
|
||||
x:Class="LibationAvalonia.Controls.DataGridTemplateColumnExt">
|
||||
|
||||
</DataGridTemplateColumn>
|
||||
@@ -0,0 +1,73 @@
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using System;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LibationAvalonia.Controls
|
||||
{
|
||||
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 DataGridTemplateColumnExt Column { get; init; }
|
||||
public GridEntry GridEntry { get; init; }
|
||||
public ContextMenu ContextMenu { get; init; }
|
||||
public AvaloniaList<MenuItem> ContextMenuItems
|
||||
=> ContextMenu.Items as AvaloniaList<MenuItem>;
|
||||
}
|
||||
|
||||
public partial class DataGridTemplateColumnExt : DataGridTemplateColumn
|
||||
{
|
||||
public event EventHandler<DataGridCellContextMenuStripNeededEventArgs> CellContextMenuStripNeeded;
|
||||
|
||||
private static readonly ContextMenu ContextMenu = new();
|
||||
private static readonly AvaloniaList<MenuItem> MenuItems = new();
|
||||
|
||||
public DataGridTemplateColumnExt()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
ContextMenu.Items = MenuItems;
|
||||
}
|
||||
|
||||
private void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
|
||||
{
|
||||
if (sender is DataGridCell cell && cell.DataContext is GridEntry entry)
|
||||
{
|
||||
var args = new DataGridCellContextMenuStripNeededEventArgs
|
||||
{
|
||||
Column = this,
|
||||
GridEntry = entry,
|
||||
ContextMenu = ContextMenu
|
||||
};
|
||||
args.ContextMenuItems.Clear();
|
||||
|
||||
CellContextMenuStripNeeded?.Invoke(sender, args);
|
||||
|
||||
e.Handled = args.ContextMenuItems.Count == 0;
|
||||
}
|
||||
else
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override IControl GenerateElement(DataGridCell cell, object dataItem)
|
||||
{
|
||||
if (cell.ContextMenu is null)
|
||||
{
|
||||
cell.ContextRequested += Cell_ContextRequested;
|
||||
cell.ContextMenu = ContextMenu;
|
||||
}
|
||||
|
||||
return base.GenerateElement(cell, dataItem);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -66,7 +66,7 @@
|
||||
<DataGridTextColumn
|
||||
Width="2*"
|
||||
Binding="{Binding AccountId, Mode=TwoWay}"
|
||||
Header="Autible
email/login"/>
|
||||
Header="Audible
email/login"/>
|
||||
|
||||
<DataGridTemplateColumn Width="Auto" Header="Locale">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
|
||||
@@ -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)) 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)) 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)
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ using LibationAvalonia.ViewModels;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
@@ -49,12 +50,12 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
|
||||
protected override void SaveAndClose()
|
||||
{
|
||||
SaveButton_Clicked(null, null);
|
||||
{
|
||||
LibraryBook.Book.UpdateUserDefinedItem(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
|
||||
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}";
|
||||
@@ -62,11 +63,9 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
|
||||
public void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
LibraryBook.Book.UpdateBook(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
|
||||
}
|
||||
=> SaveAndClose();
|
||||
|
||||
private void InitializeComponent()
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
}
|
||||
@@ -122,7 +121,7 @@ Narrator(s): {libraryBook.Book.NarratorNames()}
|
||||
Length: {(libraryBook.Book.LengthInMinutes == 0 ? "" : $"{libraryBook.Book.LengthInMinutes / 60} hr {libraryBook.Book.LengthInMinutes % 60} min")}
|
||||
Audio Bitrate: {libraryBook.Book.AudioFormat}
|
||||
Category: {string.Join(" > ", libraryBook.Book.CategoriesNames())}
|
||||
Purchase Date: {libraryBook.DateAdded.ToString("d")}
|
||||
Purchase Date: {libraryBook.DateAdded:d}
|
||||
Audible ID: {libraryBook.Book.AudibleProductId}
|
||||
".Trim();
|
||||
|
||||
|
||||
@@ -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
Replace"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
IsReadOnly="False"
|
||||
Binding="{Binding ReplacementText, Mode=TwoWay}"
|
||||
Header="Replacement
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
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>
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||