mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-02 10:58:43 -05:00
Compare commits
62 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 | ||
|
|
a01a8c4b19 |
8
.github/dependabot.yml
vendored
Normal file
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
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
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
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
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 }}
|
||||
44
.github/workflows/dotnet-build.yml
vendored
44
.github/workflows/dotnet-build.yml
vendored
@@ -1,44 +0,0 @@
|
||||
# # This workflow will build a .NET project
|
||||
# # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
#
|
||||
# name: build
|
||||
#
|
||||
# on:
|
||||
# workflow_call:
|
||||
#
|
||||
# env:
|
||||
# DOTNET_CONFIGURATION: 'Release'
|
||||
#
|
||||
# jobs:
|
||||
# build:
|
||||
# runs-on: windows-latest
|
||||
# strategy:
|
||||
# matrix:
|
||||
# os: [Linux, MacOS, Windows]
|
||||
# ui: [Avalonia]
|
||||
# release_name: [chardonnay]
|
||||
# include:
|
||||
# - os: Windows
|
||||
# ui: WinForms
|
||||
# release_name: classic
|
||||
# steps:
|
||||
# - uses: actions/checkout@v3
|
||||
# - name: Setup .NET
|
||||
# uses: actions/setup-dotnet@v3
|
||||
# with:
|
||||
# dotnet-version: '7.x'
|
||||
# env:
|
||||
# NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# - name: Build
|
||||
# working-directory: ./Source
|
||||
# run: |
|
||||
# dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}\Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
||||
# dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LoadByOS\${{ matrix.os }}ConfigApp\${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS\Properties\${{ matrix.os }}ConfigApp\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
||||
# dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} LibationCli\LibationCli.csproj -p:PublishProfile=LibationCli\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
||||
# dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin\Publish\${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}\Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}\Properties\PublishProfiles\${{ matrix.os }}Profile.pubxml
|
||||
# - name: Publish artifact
|
||||
# uses: actions/upload-artifact@v3
|
||||
# with:
|
||||
# name: ${{ matrix.os }}-${{ matrix.release_name }}
|
||||
# path: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}/*
|
||||
# if-no-files-found: error
|
||||
67
.github/workflows/dotnet-release.yml
vendored
67
.github/workflows/dotnet-release.yml
vendored
@@ -1,67 +0,0 @@
|
||||
# # This workflow will build a .NET project
|
||||
# # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
# name: release
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# tags:
|
||||
# - 'v*'
|
||||
#
|
||||
# env:
|
||||
# DOTNET_VERSION: '7' # The .NET SDK version to use
|
||||
# DOTNET_SOURCE: './Source'
|
||||
# DOTNET_CONFIGURATION: 'Release'
|
||||
#
|
||||
# jobs:
|
||||
# build:
|
||||
# uses: ./.github/workflows/dotnet-build.yml
|
||||
#
|
||||
# release:
|
||||
# needs: build
|
||||
# runs-on: ubuntu-latest
|
||||
# steps:
|
||||
# - name: Calculate version
|
||||
# id: version
|
||||
# run: |
|
||||
# export TAG=${{ github.ref_name }}
|
||||
# echo "version=${TAG#v}" >> $GITHUB_OUTPUT
|
||||
#
|
||||
# - name: Download artifacts
|
||||
# uses: actions/download-artifact@v3
|
||||
# with:
|
||||
# path: artifacts
|
||||
#
|
||||
# - name: Rename artifacts
|
||||
# id: rename
|
||||
# working-directory: ./artifacts
|
||||
# run: |
|
||||
# for FILENAME in *; do mv ${FILENAME} Libation.${{ steps.version.outputs.version }}-${FILENAME,,}; done
|
||||
# mv Libation.${{ steps.version.outputs.version }}-windows-classic Classic-Libation.${{ steps.version.outputs.version }}-windows-classic
|
||||
#
|
||||
# - name: Zip assets
|
||||
# working-directory: ./artifacts
|
||||
# run: |
|
||||
# for FILENAME in *; do zip -r ${FILENAME}.zip ${FILENAME}; done
|
||||
# mkdir ./assets
|
||||
# mv *.zip ./assets
|
||||
#
|
||||
# - name: Create release
|
||||
# id: create_release
|
||||
# uses: actions/create-release@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} # This token is provided by Actions, you do not need to create your own token
|
||||
# with:
|
||||
# tag_name: ${{ github.ref }}
|
||||
# release_name: Libation ${{ steps.version.outputs.version }}
|
||||
# body: <Put a body here>
|
||||
# draft: true
|
||||
# prerelease: false
|
||||
#
|
||||
# - name: Upload release assets
|
||||
# uses: dwenegar/upload-release-assets@v1
|
||||
# env:
|
||||
# GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
# with:
|
||||
# release_id: ${{ steps.create_release.outputs.id }}
|
||||
# assets_path: ./artifacts/assets
|
||||
#
|
||||
20
.github/workflows/dotnet-validate.yml
vendored
20
.github/workflows/dotnet-validate.yml
vendored
@@ -1,20 +0,0 @@
|
||||
# # This workflow will build a .NET project
|
||||
# # For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-net
|
||||
#
|
||||
# name: validate
|
||||
#
|
||||
# on:
|
||||
# push:
|
||||
# branches: [ "master" ]
|
||||
# pull_request:
|
||||
# branches: [ "master" ]
|
||||
#
|
||||
# env:
|
||||
# DOTNET_VERSION: '7' # The .NET SDK version to use
|
||||
# DOTNET_SLN: './Source/Libation.sln'
|
||||
# DOTNET_CONFIGURATION: 'Release'
|
||||
#
|
||||
# jobs:
|
||||
# build:
|
||||
# uses: ./.github/workflows/dotnet-build.yml
|
||||
#
|
||||
64
.github/workflows/release.yml
vendored
Normal file
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
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+-windows-classic\\.zip",
|
||||
"WindowsAvalonia":"Libation\\.\\d+\\.\\d+\\.\\d+-windows-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
|
||||
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay",
|
||||
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macos-chardonnay"
|
||||
}
|
||||
|
||||
68
Docker/liberate.sh
Executable file
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
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"]
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>8.6.3.1</Version>
|
||||
<Version>8.7.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="4.0.3" />
|
||||
|
||||
@@ -9,7 +9,6 @@ using Dinah.Core;
|
||||
using DtoImporterService;
|
||||
using LibationFileManager;
|
||||
using Serilog;
|
||||
using static System.Reflection.Metadata.BlobBuilder;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
namespace ApplicationServices
|
||||
@@ -300,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)
|
||||
{
|
||||
@@ -336,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;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="7.0.0.5" />
|
||||
<PackageReference Include="AudibleApi" Version="7.1.0.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -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
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
|
||||
|
||||
@@ -105,7 +105,7 @@ namespace FileManager
|
||||
|
||||
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
|
||||
// Esp important for file templates.
|
||||
return replacements.ReplaceInvalidFilenameChars(value.ToString());
|
||||
return replacements.ReplaceFilenameChars(value.ToString());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ namespace FileManager
|
||||
|
||||
var pathNoPrefix = path.PathWithoutPrefix;
|
||||
|
||||
pathNoPrefix = replacements.ReplaceInvalidPathChars(pathNoPrefix);
|
||||
pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix);
|
||||
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
|
||||
|
||||
return pathNoPrefix;
|
||||
|
||||
@@ -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));
|
||||
|
||||
|
||||
@@ -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
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
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; }
|
||||
}
|
||||
}
|
||||
@@ -50,7 +50,15 @@
|
||||
<None Remove="Assets\hangover.ico" />
|
||||
<None Remove="hangover.ico" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="ViewModels\MainVM.*.cs">
|
||||
<DependentUpon>MainVM.cs</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Views\MainWindow.*.cs">
|
||||
<DependentUpon>MainWindow.axaml</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="hangover.ico" />
|
||||
@@ -63,11 +71,13 @@
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.18" />
|
||||
|
||||
<PackageReference Include="Avalonia" Version="11.0.0-preview4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview4" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.18" />
|
||||
<PackageReference 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>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.ReactiveUI;
|
||||
using System;
|
||||
|
||||
|
||||
36
Source/HangoverAvalonia/ViewModels/MainVM.Database.cs
Normal file
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
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
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,37 +0,0 @@
|
||||
using System;
|
||||
using HangoverBase;
|
||||
using ReactiveUI;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels
|
||||
{
|
||||
public class MainWindowViewModel : ViewModelBase
|
||||
{
|
||||
private DatabaseTab _tab;
|
||||
|
||||
private string _databaseFileText;
|
||||
private bool _databaseFound;
|
||||
private string _sqlResults;
|
||||
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
|
||||
public string SqlQuery { get; set; }
|
||||
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
|
||||
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
|
||||
|
||||
public MainWindowViewModel()
|
||||
{
|
||||
_tab = new(new(() => SqlQuery, s => SqlResults = s, s => SqlResults = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
DatabaseFileText = $"Database file not found";
|
||||
DatabaseFound = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseFileText = $"Database file: {_tab.DbFile}";
|
||||
DatabaseFound = true;
|
||||
}
|
||||
|
||||
public void ExecuteQuery() => _tab.ExecuteQuery();
|
||||
}
|
||||
}
|
||||
@@ -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
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
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
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); };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace HangoverWinForms
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
if (!cliTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
73
Source/HangoverWinForms/Form1.Deleted.cs
Normal file
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
98
Source/HangoverWinForms/Form1.Designer.cs
generated
98
Source/HangoverWinForms/Form1.Designer.cs
generated
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
namespace HangoverWinForms
|
||||
using AppScaffolding;
|
||||
|
||||
namespace HangoverWinForms
|
||||
{
|
||||
public partial class Form1 : Form
|
||||
{
|
||||
@@ -6,11 +8,17 @@
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
var config = LibationScaffolding.RunPreConfigMigrations();
|
||||
LibationScaffolding.RunPostConfigMigrations(config);
|
||||
LibationScaffolding.RunPostMigrationScaffolding(config);
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
Load_deletedTab();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net7.0-windows</TargetFramework>
|
||||
<EnableWindowsTargeting>true</EnableWindowsTargeting>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<ApplicationIcon>hangover.ico</ApplicationIcon>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,9 +42,6 @@ namespace LibationAvalonia
|
||||
{
|
||||
LoadStyles();
|
||||
|
||||
var SEGOEUI = new Typeface(new FontFamily(new Uri("avares://Libation/Assets/WINGDING.TTF"), "SEGOEUI_Local"));
|
||||
var gtf = FontManager.Current.GetOrAddGlyphTypeface(SEGOEUI);
|
||||
|
||||
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (SetupRequired)
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
|
||||
<Styles.Resources>
|
||||
<Color x:Key="SeriesEntryGridBackgroundColor">#FFE6FFE6</Color>
|
||||
<Color x:Key="SeriesEntryGridBackgroundColor">#cdffcd</Color>
|
||||
|
||||
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Color="{StaticResource SeriesEntryGridBackgroundColor}" />
|
||||
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.5" Color="{StaticResource SeriesEntryGridBackgroundColor}" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookFailedBrush" Color="LightCoral" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCompletedBrush" Color="PaleGreen" />
|
||||
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
|
||||
|
||||
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -54,7 +55,7 @@ namespace LibationAvalonia.Dialogs
|
||||
base.SaveAndClose();
|
||||
}
|
||||
|
||||
public void GoToAudible_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
public void GoToAudible_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
var locale = AudibleApi.Localization.Get(_libraryBook.Book.Locale);
|
||||
var link = $"https://www.audible.{locale.TopDomain}/pd/{_libraryBook.Book.AudibleProductId}";
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using System;
|
||||
using System.ComponentModel;
|
||||
using System.IO;
|
||||
using ReactiveUI;
|
||||
using Avalonia.Platform.Storage;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
@@ -46,27 +47,30 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
public async void SaveImage_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var options = new FilePickerSaveOptions
|
||||
{
|
||||
Title = $"Save Sover Image",
|
||||
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Environment.GetFolderPath(Environment.SpecialFolder.MyPictures)),
|
||||
SuggestedFileName = $"{PictureFileName}.jpg",
|
||||
DefaultExtension = "jpg",
|
||||
ShowOverwritePrompt = true,
|
||||
FileTypeChoices = new FilePickerFileType[]
|
||||
{
|
||||
new("Jpeg (*.jpg)") { Patterns = new[] { "jpg" } }
|
||||
}
|
||||
};
|
||||
|
||||
SaveFileDialog saveFileDialog = new();
|
||||
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Jpeg", Extensions = new System.Collections.Generic.List<string>() { "jpg" } });
|
||||
saveFileDialog.InitialFileName = PictureFileName;
|
||||
saveFileDialog.Directory
|
||||
= !LibationFileManager.Configuration.IsWindows ? null
|
||||
: Directory.Exists(BookSaveDirectory) ? BookSaveDirectory
|
||||
: Path.GetDirectoryName(BookSaveDirectory);
|
||||
var selectedFile = await StorageProvider.SaveFilePickerAsync(options);
|
||||
|
||||
var fileName = await saveFileDialog.ShowAsync(this);
|
||||
|
||||
if (fileName is null)
|
||||
return;
|
||||
if (!selectedFile.TryGetUri(out var uri)) return;
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllBytes(fileName, CoverBytes);
|
||||
File.WriteAllBytes(uri.LocalPath, CoverBytes);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to save picture to {fileName}");
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to save picture to {uri.LocalPath}");
|
||||
await MessageBox.Show(this, $"An error was encountered while trying to save the picture\r\n\r\n{ex.Message}", "Failed to save picture", MessageBoxButtons.OK, MessageBoxIcon.Error, MessageBoxDefaultButton.Button1);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace LibationAvalonia.Dialogs.Login
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
public async void ExternalLoginLink_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
public async void ExternalLoginLink_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
LoginMethod = LoginMethod.External;
|
||||
await SaveAndCloseAsync();
|
||||
|
||||
@@ -28,7 +28,7 @@ namespace LibationAvalonia.Dialogs
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
private async void GoToGithub_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
private async void GoToGithub_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
var url = "https://github.com/rmcrackan/Libation/issues";
|
||||
try
|
||||
@@ -41,7 +41,7 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
private async void GoToLogs_Tapped(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
private async void GoToLogs_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
{
|
||||
LongPath dir = "";
|
||||
try
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
|
||||
MinWidth="265" MinHeight="110"
|
||||
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
|
||||
Title="{Binding Caption}" HasSystemDecorations="True" ShowInTaskbar="True"
|
||||
Title="{Binding Caption}" ShowInTaskbar="True"
|
||||
Icon="/Assets/1x1.png">
|
||||
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
|
||||
|
||||
@@ -34,13 +34,13 @@
|
||||
</Style>
|
||||
</DockPanel.Styles>
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
|
||||
<Button Grid.Column="0" MinWidth="75" MinHeight="25" Name="Button1" Click="Button1_Click" Margin="5">
|
||||
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding Button1Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="25" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding Button2Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="25" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
|
||||
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Content="Cancel" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" Text="{Binding Button3Text}"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
|
||||
@@ -37,10 +37,13 @@ Find books that you haven't rated:
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchBoolFields());
|
||||
|
||||
IdFields = @"
|
||||
Alice's Adventures in Wonderland (ID: B015D78L0U)
|
||||
Alice's Adventures in
|
||||
Wonderland (ID: B015D78L0U)
|
||||
|
||||
id:B015D78L0U
|
||||
|
||||
All of these are synonyms for the ID field
|
||||
All of these are synonyms
|
||||
for the ID field
|
||||
|
||||
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchIdFields());
|
||||
|
||||
@@ -23,12 +23,12 @@
|
||||
<TabControl Grid.Column="0">
|
||||
<TabControl.Styles>
|
||||
<Style Selector="ItemsPresenter#PART_ItemsPresenter">
|
||||
<Setter Property="Height" Value="18"/>
|
||||
<Setter Property="Height" Value="28"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem">
|
||||
<Setter Property="MinHeight" Value="30"/>
|
||||
<Setter Property="Height" Value="30"/>
|
||||
<Setter Property="Padding" Value="8,2,8,0"/>
|
||||
<Setter Property="MinHeight" Value="40"/>
|
||||
<Setter Property="Height" Value="40"/>
|
||||
<Setter Property="Padding" Value="8,2,8,10"/>
|
||||
</Style>
|
||||
<Style Selector="TabItem#Header TextBlock">
|
||||
<Setter Property="MinHeight" Value="5"/>
|
||||
@@ -344,7 +344,6 @@
|
||||
<Button
|
||||
Grid.Row="6"
|
||||
Grid.Column="0"
|
||||
IsEnabled="False"
|
||||
Content="{Binding DownloadDecryptSettings.EditCharReplacementText}"
|
||||
Height="30"
|
||||
Padding="30,3,30,3"
|
||||
@@ -352,21 +351,27 @@
|
||||
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
|
||||
<StackPanel
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
BorderWidth="1"
|
||||
Label="Temporary Files Location">
|
||||
|
||||
<StackPanel
|
||||
Margin="5" >
|
||||
|
||||
<TextBlock
|
||||
Margin="0,0,0,10"
|
||||
Text="{Binding DownloadDecryptSettings.InProgressDescriptionText}" />
|
||||
|
||||
<controls:DirectorySelectControl
|
||||
SubDirectory="Libation\DecryptInProgress"
|
||||
SelectedDirectory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}" />
|
||||
<controls:DirectoryOrCustomSelectControl
|
||||
SubDirectory="Libation"
|
||||
Directory="{Binding DownloadDecryptSettings.InProgressDirectory, Mode=TwoWay}"
|
||||
KnownDirectories="{Binding DownloadDecryptSettings.KnownDirectories}" />
|
||||
|
||||
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
<CheckBox
|
||||
Grid.Row="3"
|
||||
|
||||
@@ -9,6 +9,7 @@ using ReactiveUI;
|
||||
using Dinah.Core;
|
||||
using System.Linq;
|
||||
using FileManager;
|
||||
using System.IO;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
@@ -69,13 +70,10 @@ namespace LibationAvalonia.Dialogs
|
||||
settingsDisp.DownloadDecryptSettings.ChapterFileTemplate = newTemplate;
|
||||
}
|
||||
|
||||
public void EditCharReplacementButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
public async void EditCharReplacementButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
/*
|
||||
var form = new LibationAvalonia.Dialogs.EditReplacementChars(config);
|
||||
form.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
|
||||
form.ShowDialog();
|
||||
*/
|
||||
var form = new EditReplacementChars(config);
|
||||
await form.ShowDialog<DialogResult>(this);
|
||||
}
|
||||
|
||||
public async void EditChapterTitleTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
@@ -230,7 +228,6 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
public class DownloadDecryptSettings : ViewModels.ViewModelBase, ISettingsDisplay
|
||||
{
|
||||
|
||||
private bool _badBookAsk;
|
||||
private bool _badBookAbort;
|
||||
private bool _badBookRetry;
|
||||
@@ -245,7 +242,16 @@ namespace LibationAvalonia.Dialogs
|
||||
LoadSettings(config);
|
||||
}
|
||||
|
||||
public Configuration.KnownDirectories InProgressDirectory { get; set; }
|
||||
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
|
||||
{
|
||||
Configuration.KnownDirectories.WinTemp,
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
Configuration.KnownDirectories.AppDir,
|
||||
Configuration.KnownDirectories.MyDocs,
|
||||
Configuration.KnownDirectories.LibationFiles
|
||||
};
|
||||
|
||||
public string InProgressDirectory { get; set; }
|
||||
public void LoadSettings(Configuration config)
|
||||
{
|
||||
BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask;
|
||||
@@ -255,9 +261,7 @@ namespace LibationAvalonia.Dialogs
|
||||
FolderTemplate = config.FolderTemplate;
|
||||
FileTemplate = config.FileTemplate;
|
||||
ChapterFileTemplate = config.ChapterFileTemplate;
|
||||
InProgressDirectory
|
||||
= config.InProgress == Configuration.AppDir_Absolute ? Configuration.KnownDirectories.AppDir
|
||||
: Configuration.GetKnownDirectory(config.InProgress);
|
||||
InProgressDirectory = config.InProgress;
|
||||
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
|
||||
}
|
||||
|
||||
@@ -292,9 +296,7 @@ namespace LibationAvalonia.Dialogs
|
||||
config.FolderTemplate = FolderTemplate;
|
||||
config.FileTemplate = FileTemplate;
|
||||
config.ChapterFileTemplate = ChapterFileTemplate;
|
||||
config.InProgress
|
||||
= InProgressDirectory is Configuration.KnownDirectories.AppDir ? Configuration.AppDir_Absolute
|
||||
: Configuration.GetKnownDirectoryPath(InProgressDirectory);
|
||||
config.InProgress = InProgressDirectory;
|
||||
|
||||
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
|
||||
|
||||
|
||||
@@ -2,17 +2,17 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="330"
|
||||
MinWidth="500" MinHeight="330"
|
||||
MaxWidth="500" MaxHeight="330"
|
||||
mc:Ignorable="d" d:DesignWidth="500" d:DesignHeight="350"
|
||||
MinWidth="500" MinHeight="350"
|
||||
MaxWidth="500" MaxHeight="350"
|
||||
x:Class="LibationAvalonia.Dialogs.SetupDialog"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Icon="/Assets/libation.ico"
|
||||
Title="Welcome to Libation">
|
||||
|
||||
<Grid Margin="10" RowDefinitions="*,Auto,Auto">
|
||||
<Grid Margin="10" ColumnDefinitions="*" RowDefinitions="*,Auto,Auto">
|
||||
|
||||
<TextBlock Grid.Row="0" Text="This appears to be your first time using Libation or a previous setup was incomplete.
|
||||
<TextBlock Grid.Row="0" TextWrapping="Wrap" Text="This appears to be your first time using Libation or a previous setup was incomplete.
|
||||


|
||||

Please fill in a few settings. You can also change these settings later.
|
||||


|
||||
@@ -22,11 +22,10 @@
|
||||

Download your entire library from the "Liberate" tab or
|
||||

liberate your books one at a time by clicking the stoplight." />
|
||||
|
||||
<Button
|
||||
<Button
|
||||
Grid.Row="1"
|
||||
Margin="0,10,0,10"
|
||||
Padding="0,10,0,10"
|
||||
HorizontalAlignment="Stretch"
|
||||
Width="480"
|
||||
Margin="0,0,0,10"
|
||||
Click="NewUser_Click">
|
||||
|
||||
<TextBlock
|
||||
@@ -35,12 +34,11 @@
|
||||
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
<Button
|
||||
Grid.Row="2"
|
||||
Padding="0,10,0,10"
|
||||
HorizontalAlignment="Stretch"
|
||||
Width="480"
|
||||
Click="ReturningUser_Click">
|
||||
|
||||
|
||||
<TextBlock
|
||||
TextAlignment="Center"
|
||||
Text="RETURNING USER

I have previously installed Libation. This is an upgrade or re-install."/>
|
||||
|
||||
@@ -18,137 +18,121 @@
|
||||
<StartupObject />
|
||||
</PropertyGroup>
|
||||
|
||||
<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>
|
||||
<SatelliteResourceLanguages>en;es</SatelliteResourceLanguages>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Debug</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Release</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<AvaloniaResource Remove="Assets\SEGOEUI.TTF" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\Asterisk.png" />
|
||||
<None Remove="Assets\cancel.png" />
|
||||
<None Remove="Assets\completed.png" />
|
||||
<None Remove="Assets\down.png" />
|
||||
<None Remove="Assets\download-arrow.png" />
|
||||
<None Remove="Assets\edit-tags-25x25.png" />
|
||||
<None Remove="Assets\edit-tags-50x50.png" />
|
||||
<None Remove="Assets\edit_25x25.png" />
|
||||
<None Remove="Assets\edit_64x64.png" />
|
||||
<None Remove="Assets\error.png" />
|
||||
<None Remove="Assets\errored.png" />
|
||||
<None Remove="Assets\Exclamation.png" />
|
||||
<None Remove="Assets\first.png" />
|
||||
<None Remove="Assets\glass-with-glow_16.png" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_300x300.jpg" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_500x500.jpg" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_80x80.jpg" />
|
||||
<None Remove="Assets\import_16x16.png" />
|
||||
<None Remove="Assets\last.png" />
|
||||
<None Remove="Assets\libation.ico" />
|
||||
<None Remove="Assets\LibationStyles.xaml" />
|
||||
<None Remove="Assets\liberate_green.png" />
|
||||
<None Remove="Assets\liberate_green_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_green_pdf_yes.png" />
|
||||
<None Remove="Assets\liberate_red.png" />
|
||||
<None Remove="Assets\liberate_red_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_red_pdf_yes.png" />
|
||||
<None Remove="Assets\liberate_yellow.png" />
|
||||
<None Remove="Assets\liberate_yellow_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_yellow_pdf_yes.png" />
|
||||
<None Remove="Assets\MBIcons\Asterisk.png" />
|
||||
<None Remove="Assets\MBIcons\error.png" />
|
||||
<None Remove="Assets\MBIcons\Exclamation.png" />
|
||||
<None Remove="Assets\MBIcons\Question.png" />
|
||||
<None Remove="Assets\minus.png" />
|
||||
<None Remove="Assets\plus.png" />
|
||||
<None Remove="Assets\Question.png" />
|
||||
<None Remove="Assets\queued.png" />
|
||||
<None Remove="Assets\SEGOEUI.TTF" />
|
||||
<None Remove="Assets\up.png" />
|
||||
<None Remove="Assets\WINGDING.TTF" />
|
||||
</ItemGroup>
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
|
||||
<OutputPath>..\bin\Avalonia\Release</OutputPath>
|
||||
<DebugType>embedded</DebugType>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<AvaloniaResource Include="Assets\**" />
|
||||
<None Remove=".gitignore" />
|
||||
<None Remove="Assets\Asterisk.png" />
|
||||
<None Remove="Assets\cancel.png" />
|
||||
<None Remove="Assets\completed.png" />
|
||||
<None Remove="Assets\down.png" />
|
||||
<None Remove="Assets\download-arrow.png" />
|
||||
<None Remove="Assets\edit-tags-25x25.png" />
|
||||
<None Remove="Assets\edit-tags-50x50.png" />
|
||||
<None Remove="Assets\edit_25x25.png" />
|
||||
<None Remove="Assets\edit_64x64.png" />
|
||||
<None Remove="Assets\error.png" />
|
||||
<None Remove="Assets\errored.png" />
|
||||
<None Remove="Assets\Exclamation.png" />
|
||||
<None Remove="Assets\first.png" />
|
||||
<None Remove="Assets\glass-with-glow_16.png" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_300x300.jpg" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_500x500.jpg" />
|
||||
<None Remove="Assets\img-coverart-prod-unavailable_80x80.jpg" />
|
||||
<None Remove="Assets\import_16x16.png" />
|
||||
<None Remove="Assets\last.png" />
|
||||
<None Remove="Assets\libation.ico" />
|
||||
<None Remove="Assets\LibationStyles.xaml" />
|
||||
<None Remove="Assets\liberate_green.png" />
|
||||
<None Remove="Assets\liberate_green_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_green_pdf_yes.png" />
|
||||
<None Remove="Assets\liberate_red.png" />
|
||||
<None Remove="Assets\liberate_red_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_red_pdf_yes.png" />
|
||||
<None Remove="Assets\liberate_yellow.png" />
|
||||
<None Remove="Assets\liberate_yellow_pdf_no.png" />
|
||||
<None Remove="Assets\liberate_yellow_pdf_yes.png" />
|
||||
<None Remove="Assets\MBIcons\Asterisk.png" />
|
||||
<None Remove="Assets\MBIcons\error.png" />
|
||||
<None Remove="Assets\MBIcons\Exclamation.png" />
|
||||
<None Remove="Assets\MBIcons\Question.png" />
|
||||
<None Remove="Assets\minus.png" />
|
||||
<None Remove="Assets\plus.png" />
|
||||
<None Remove="Assets\Question.png" />
|
||||
<None Remove="Assets\queued.png" />
|
||||
<None Remove="Assets\up.png" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Update="Dialogs\LiberatedStatusBatchAutoDialog.axaml.cs">
|
||||
<DependentUpon>LiberatedStatusBatchAutoDialog.axaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Dialogs\LiberatedStatusBatchManualDialog.axaml.cs">
|
||||
<DependentUpon>LiberatedStatusBatchManualDialog.axaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Views\ProcessBookControl.axaml.cs">
|
||||
<DependentUpon>ProcessBookControl.axaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Views\ProcessQueueControl.axaml.cs">
|
||||
<DependentUpon>ProcessQueueControl.axaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Views\ProductsDisplay.axaml.cs">
|
||||
<DependentUpon>ProductsDisplay.axaml</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
|
||||
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<Compile Update="Properties\Resources.Designer.cs">
|
||||
<DesignTime>True</DesignTime>
|
||||
<AutoGen>True</AutoGen>
|
||||
<DependentUpon>Resources.resx</DependentUpon>
|
||||
</Compile>
|
||||
<Compile Update="Views\MainWindow.*.cs">
|
||||
<DependentUpon>MainWindow.axaml</DependentUpon>
|
||||
</Compile>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<UpToDateCheckInput Remove="Controls\GroupBox.axaml" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Update="Properties\Resources.resx">
|
||||
<Generator>ResXFileCodeGenerator</Generator>
|
||||
<LastGenOutput>Resources.Designer.cs</LastGenOutput>
|
||||
</EmbeddedResource>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<EmbeddedResource Include="Assets\SEGOEUI.TTF" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<UpToDateCheckInput Remove="Controls\GroupBox.axaml" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="0.10.18" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="0.10.18" />
|
||||
<PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia" Version="11.0.0-preview4" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-preview4" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview4" />
|
||||
<PackageReference 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="Avalonia.Xaml.Behaviors" Version="11.0.0-preview4" />
|
||||
<PackageReference Include="XamlNameReferenceGenerator" Version="1.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="glass-with-glow_256.svg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Libation.desktop">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ZipExtractor.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="glass-with-glow_256.svg">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="Libation.desktop">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
<None Update="ZipExtractor.exe">
|
||||
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
|
||||
</None>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<Target Name="SpicNSpan" AfterTargets="Clean">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using Avalonia.Threading;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia
|
||||
@@ -40,19 +41,15 @@ namespace LibationAvalonia
|
||||
|
||||
private static async void LogMe_LogError(object sender, (Exception, string) tuple)
|
||||
{
|
||||
await Task.Run(() => LogForm?.WriteLine(tuple.Item2 ?? "Automated backup: error"));
|
||||
await Task.Run(() => LogForm?.WriteLine("ERROR: " + tuple.Item1.Message));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(tuple.Item2 ?? "Automated backup: error"));
|
||||
await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine("ERROR: " + tuple.Item1.Message));
|
||||
}
|
||||
|
||||
private static async void LogMe_LogErrorString(object sender, string text)
|
||||
{
|
||||
await Task.Run(() => LogForm?.WriteLine(text));
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(text));
|
||||
|
||||
private static async void LogMe_LogInfo(object sender, string text)
|
||||
{
|
||||
await Task.Run(() => LogForm?.WriteLine(text));
|
||||
}
|
||||
=> await Dispatcher.UIThread.InvokeAsync(() => LogForm?.WriteLine(text));
|
||||
|
||||
public void Info(string text) => LogInfo?.Invoke(this, text);
|
||||
public void Error(string text) => LogErrorString?.Invoke(this, text);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
@@ -68,8 +69,9 @@ namespace LibationAvalonia
|
||||
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
catch (Exception exDebug)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ namespace LibationAvalonia.ViewModels
|
||||
[Browsable(false)] public string LongDescription { get; protected set; }
|
||||
[Browsable(false)] public abstract DateTime DateAdded { get; }
|
||||
[Browsable(false)] public int ListIndex { get; set; }
|
||||
[Browsable(false)] protected Book Book => LibraryBook.Book;
|
||||
[Browsable(false)] public Book Book => LibraryBook.Book;
|
||||
|
||||
#region Model properties exposed to the view
|
||||
|
||||
@@ -51,7 +51,8 @@ namespace LibationAvalonia.ViewModels
|
||||
public abstract bool IsSeries { get; }
|
||||
public abstract bool IsEpisode { get; }
|
||||
public abstract bool IsBook { get; }
|
||||
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : null;
|
||||
public abstract double Opacity { get; }
|
||||
public IBrush BackgroundBrush => IsEpisode ? App.SeriesEntryGridBackgroundBrush : Brushes.Transparent;
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
@@ -1,177 +0,0 @@
|
||||
using ApplicationServices;
|
||||
using LibationSearchEngine;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Collections.Specialized;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
/*
|
||||
* Allows filtering of the underlying ObservableCollection<GridEntry>
|
||||
*
|
||||
* When filtering is applied, the filtered-out items are removed
|
||||
* from the base list and added to the private FilterRemoved list.
|
||||
* When filtering is removed, items in the FilterRemoved list are
|
||||
* added back to the base list.
|
||||
*
|
||||
* Items are added and removed to/from the ObservableCollection's
|
||||
* internal list instead of the ObservableCollection itself to
|
||||
* avoid ObservableCollection firing CollectionChanged for every
|
||||
* item. Editing the list this way improve's display performance,
|
||||
* but requires ResetCollection() to be called after all changes
|
||||
* have been made.
|
||||
*/
|
||||
public class GridEntryCollection : ObservableCollection<GridEntry>
|
||||
{
|
||||
public GridEntryCollection(IEnumerable<GridEntry> enumeration)
|
||||
: base(new List<GridEntry>(enumeration)) { }
|
||||
public GridEntryCollection(List<GridEntry> list)
|
||||
: base(list) { }
|
||||
|
||||
public List<GridEntry> InternalList => Items as List<GridEntry>;
|
||||
/// <returns>All items in the list, including those filtered out.</returns>
|
||||
public List<GridEntry> AllItems() => Items.Concat(FilterRemoved).ToList();
|
||||
|
||||
/// <summary>When true, itms will not be checked filtered by search criteria on item changed<summary>
|
||||
public bool SuspendFilteringOnUpdate { get; set; }
|
||||
public string Filter { get => FilterString; set => ApplyFilter(value); }
|
||||
|
||||
/// <summary> Items that were removed from the base list due to filtering </summary>
|
||||
private readonly List<GridEntry> FilterRemoved = new();
|
||||
private string FilterString;
|
||||
private SearchResultSet SearchResults;
|
||||
|
||||
#region Items Management
|
||||
|
||||
/// <summary>
|
||||
/// Removes all items from the collection, both visible and hidden, adds new items to the visible collection.
|
||||
/// </summary>
|
||||
public void ReplaceList(IEnumerable<GridEntry> newItems)
|
||||
{
|
||||
Items.Clear();
|
||||
FilterRemoved.Clear();
|
||||
((List<GridEntry>)Items).AddRange(newItems);
|
||||
ResetCollection();
|
||||
}
|
||||
public void ResetCollection()
|
||||
=> OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset));
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filtering
|
||||
|
||||
|
||||
private void ApplyFilter(string filterString)
|
||||
{
|
||||
if (filterString != FilterString)
|
||||
RemoveFilter();
|
||||
|
||||
FilterString = filterString;
|
||||
SearchResults = SearchEngineCommands.Search(filterString);
|
||||
|
||||
var booksFilteredIn = Items.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
|
||||
|
||||
//Find all series containing children that match the search criteria
|
||||
var seriesFilteredIn = Items.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
|
||||
|
||||
var filteredOut = Items.Except(booksFilteredIn.Concat(seriesFilteredIn)).ToList();
|
||||
|
||||
foreach (var item in filteredOut)
|
||||
{
|
||||
FilterRemoved.Add(item);
|
||||
Items.Remove(item);
|
||||
}
|
||||
ResetCollection();
|
||||
}
|
||||
|
||||
public void RemoveFilter()
|
||||
{
|
||||
if (FilterString is null) return;
|
||||
|
||||
int visibleCount = Items.Count;
|
||||
|
||||
foreach (var item in FilterRemoved.ToList())
|
||||
{
|
||||
if (item is SeriesEntry || item is LibraryBookEntry lbe && (lbe.Parent is null || lbe.Parent.Liberate.Expanded))
|
||||
{
|
||||
|
||||
FilterRemoved.Remove(item);
|
||||
Items.Insert(visibleCount++, item);
|
||||
}
|
||||
}
|
||||
|
||||
FilterString = null;
|
||||
SearchResults = null;
|
||||
ResetCollection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Expand/Collapse
|
||||
|
||||
public void CollapseAll()
|
||||
{
|
||||
foreach (var series in Items.SeriesEntries().ToList())
|
||||
CollapseItem(series);
|
||||
}
|
||||
|
||||
public void ExpandAll()
|
||||
{
|
||||
foreach (var series in Items.SeriesEntries().ToList())
|
||||
ExpandItem(series);
|
||||
}
|
||||
|
||||
public void CollapseItem(SeriesEntry sEntry)
|
||||
{
|
||||
foreach (var episode in Items.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList())
|
||||
{
|
||||
/*
|
||||
* Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't
|
||||
* fired. When adding or removing many items at once, Avalonia's CollectionChanged
|
||||
* event handler causes serious performance problems. And unfotrunately, Avalonia
|
||||
* doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems)
|
||||
* overload that would fire only once for all changed items.
|
||||
*
|
||||
* Doing this requires resetting the list so the view knows it needs to rebuild its display.
|
||||
*/
|
||||
|
||||
FilterRemoved.Add(episode);
|
||||
Items.Remove(episode);
|
||||
}
|
||||
|
||||
sEntry.Liberate.Expanded = false;
|
||||
ResetCollection();
|
||||
}
|
||||
|
||||
public void ExpandItem(SeriesEntry sEntry)
|
||||
{
|
||||
var sindex = Items.IndexOf(sEntry);
|
||||
|
||||
foreach (var episode in FilterRemoved.BookEntries().Where(b => b.Parent == sEntry).OrderByDescending(lbe => lbe.SeriesIndex).ToList())
|
||||
{
|
||||
if (SearchResults is null || SearchResults.Docs.Any(d => d.ProductId == episode.AudibleProductId))
|
||||
{
|
||||
/*
|
||||
* Bypass ObservationCollection's InsertItem method so that CollectionChanged isn't
|
||||
* fired. When adding or removing many items at once, Avalonia's CollectionChanged
|
||||
* event handler causes serious performance problems. And unfotrunately, Avalonia
|
||||
* doesn't respect the NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction action, IList? changedItems)
|
||||
* overload that would fire only once for all changed items.
|
||||
*
|
||||
* Doing this requires resetting the list so the view knows it needs to rebuild its display.
|
||||
*/
|
||||
|
||||
FilterRemoved.Remove(episode);
|
||||
Items.Insert(++sindex, episode);
|
||||
}
|
||||
}
|
||||
|
||||
sEntry.Liberate.Expanded = true;
|
||||
ResetCollection();
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
}
|
||||
@@ -53,6 +53,7 @@ namespace LibationAvalonia.ViewModels
|
||||
public override bool IsSeries => false;
|
||||
public override bool IsEpisode => Parent is not null;
|
||||
public override bool IsBook => Parent is null;
|
||||
public override double Opacity => Book.UserDefinedItem.Tags.ToLower().Contains("hidden") ? 0.4 : 1;
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -99,6 +100,7 @@ namespace LibationAvalonia.ViewModels
|
||||
case nameof(udi.Tags):
|
||||
Book.UserDefinedItem.Tags = udi.Tags;
|
||||
this.RaisePropertyChanged(nameof(BookTags));
|
||||
this.RaisePropertyChanged(nameof(Opacity));
|
||||
break;
|
||||
case nameof(udi.BookStatus):
|
||||
Book.UserDefinedItem.BookStatus = udi.BookStatus;
|
||||
|
||||
@@ -3,6 +3,8 @@ using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using AudibleApi;
|
||||
using AudibleApi.Common;
|
||||
using Avalonia.Media;
|
||||
using Avalonia.Media.Imaging;
|
||||
using DataLayer;
|
||||
@@ -22,7 +24,9 @@ namespace LibationAvalonia.ViewModels
|
||||
ValidationFail,
|
||||
FailedRetry,
|
||||
FailedSkip,
|
||||
FailedAbort
|
||||
FailedAbort,
|
||||
LicenseDenied,
|
||||
LicenseDeniedPossibleOutage
|
||||
}
|
||||
|
||||
public enum ProcessBookStatus
|
||||
@@ -80,6 +84,8 @@ namespace LibationAvalonia.ViewModels
|
||||
ProcessBookResult.FailedRetry => "Error, will retry later",
|
||||
ProcessBookResult.FailedSkip => "Error, Skippping",
|
||||
ProcessBookResult.FailedAbort => "Error, Abort",
|
||||
ProcessBookResult.LicenseDenied => "License Denied",
|
||||
ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption",
|
||||
_ => Status.ToString(),
|
||||
};
|
||||
|
||||
@@ -134,18 +140,31 @@ namespace LibationAvalonia.ViewModels
|
||||
return Result = ProcessBookResult.Success;
|
||||
else if (statusHandler.Errors.Contains("Cancelled"))
|
||||
{
|
||||
Logger.Info($"{procName}: Process was cancelled {LibraryBook.Book}");
|
||||
Logger.Info($"{procName}: Process was cancelled - {LibraryBook.Book}");
|
||||
return Result = ProcessBookResult.Cancelled;
|
||||
}
|
||||
else if (statusHandler.Errors.Contains("Validation failed"))
|
||||
{
|
||||
Logger.Info($"{procName}: Validation failed {LibraryBook.Book}");
|
||||
Logger.Info($"{procName}: Validation failed - {LibraryBook.Book}");
|
||||
return Result = ProcessBookResult.ValidationFail;
|
||||
}
|
||||
|
||||
foreach (var errorMessage in statusHandler.Errors)
|
||||
Logger.Error($"{procName}: {errorMessage}");
|
||||
}
|
||||
catch (ContentLicenseDeniedException ldex)
|
||||
{
|
||||
if (ldex.AYCL?.RejectionReason is null or RejectionReason.GenericError)
|
||||
{
|
||||
Logger.Info($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
|
||||
return Result = ProcessBookResult.LicenseDeniedPossibleOutage;
|
||||
}
|
||||
else
|
||||
{
|
||||
Logger.Info($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
|
||||
return Result = ProcessBookResult.LicenseDenied;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Logger.Error(ex, procName);
|
||||
|
||||
@@ -163,6 +163,8 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
using var counterTimer = new System.Threading.Timer(CounterTimer_Tick, null, 0, 500);
|
||||
|
||||
bool shownServiceOutageMessage = false;
|
||||
|
||||
while (Queue.MoveNext())
|
||||
{
|
||||
var nextBook = Queue.Current;
|
||||
@@ -178,7 +180,19 @@ namespace LibationAvalonia.ViewModels
|
||||
else if (result == ProcessBookResult.FailedAbort)
|
||||
Queue.ClearQueue();
|
||||
else if (result == ProcessBookResult.FailedSkip)
|
||||
nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error);
|
||||
nextBook.LibraryBook.Book.UpdateBookStatus(LiberatedStatus.Error);
|
||||
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage)
|
||||
{
|
||||
await MessageBox.Show(@$"
|
||||
You were denied a content license for {nextBook.LibraryBook.Book.Title}
|
||||
|
||||
This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app.
|
||||
",
|
||||
"Possible Interruption of Service",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Asterisk);
|
||||
shownServiceOutageMessage = true;
|
||||
}
|
||||
}
|
||||
Serilog.Log.Logger.Information("Completed processing queue");
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
using Avalonia.Controls;
|
||||
using DataLayer;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -6,13 +5,11 @@ using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ReactiveUI;
|
||||
using System.Reflection;
|
||||
using System.Collections;
|
||||
using Avalonia.Threading;
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using LibationAvalonia.Views;
|
||||
using LibationAvalonia.Dialogs.Login;
|
||||
using Avalonia.Collections;
|
||||
|
||||
namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
@@ -21,113 +18,64 @@ namespace LibationAvalonia.ViewModels
|
||||
/// <summary>Number of visible rows has changed</summary>
|
||||
public event EventHandler<int> VisibleCountChanged;
|
||||
public event EventHandler<int> RemovableCountChanged;
|
||||
public event EventHandler InitialLoaded;
|
||||
|
||||
private DataGridColumn _currentSortColumn;
|
||||
private DataGrid productsDataGrid;
|
||||
/// <summary>Backing list of all grid entries</summary>
|
||||
private readonly List<GridEntry> SOURCE = new();
|
||||
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
|
||||
private List<GridEntry> FilteredInGridEntries;
|
||||
public string FilterString { get; private set; }
|
||||
public DataGridCollectionView GridEntries { get; }
|
||||
|
||||
private GridEntryCollection _gridEntries;
|
||||
private bool _removeColumnVisivle;
|
||||
public GridEntryCollection GridEntries { get => _gridEntries; private set => this.RaiseAndSetIfChanged(ref _gridEntries, value); }
|
||||
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); }
|
||||
|
||||
public List<LibraryBook> GetVisibleBookEntries()
|
||||
=> GridEntries.InternalList
|
||||
.BookEntries()
|
||||
=> GridEntries
|
||||
.OfType<LibraryBookEntry>()
|
||||
.Select(lbe => lbe.LibraryBook)
|
||||
.ToList();
|
||||
public IEnumerable<LibraryBookEntry> GetAllBookEntries()
|
||||
=> GridEntries
|
||||
.AllItems()
|
||||
|
||||
private IEnumerable<LibraryBookEntry> GetAllBookEntries()
|
||||
=> SOURCE
|
||||
.BookEntries();
|
||||
public ProductsDisplayViewModel() { }
|
||||
public ProductsDisplayViewModel(List<GridEntry> items)
|
||||
|
||||
public ProductsDisplayViewModel()
|
||||
{
|
||||
GridEntries = new GridEntryCollection(items);
|
||||
GridEntries = new(SOURCE);
|
||||
GridEntries.Filter = CollectionFilter;
|
||||
|
||||
GridEntries.CollectionChanged += (s, e)
|
||||
=> VisibleCountChanged?.Invoke(this, GridEntries.OfType<LibraryBookEntry>().Count());
|
||||
}
|
||||
|
||||
#region Display Functions
|
||||
|
||||
/// <summary>
|
||||
/// Call once on load so we can modify access a private member with reflection
|
||||
/// </summary>
|
||||
public void RegisterCollectionChanged(ProductsDisplay productsDisplay = null)
|
||||
{
|
||||
productsDataGrid ??= productsDisplay?.productsGrid;
|
||||
|
||||
if (GridEntries is null)
|
||||
return;
|
||||
|
||||
//Avalonia displays items in the DataConncetion from an internal copy of
|
||||
//the bound list, not the actual bound list. So we need to reflect to get
|
||||
//the current display order and set each GridEntry.ListIndex correctly.
|
||||
var DataConnection_PI = typeof(DataGrid).GetProperty("DataConnection", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
var DataSource_PI = DataConnection_PI.PropertyType.GetProperty("DataSource", BindingFlags.Public | BindingFlags.Instance);
|
||||
|
||||
GridEntries.CollectionChanged += (s, e) =>
|
||||
{
|
||||
if (s != GridEntries) return;
|
||||
|
||||
var displayListGE = ((IEnumerable)DataSource_PI.GetValue(DataConnection_PI.GetValue(productsDataGrid))).Cast<GridEntry>();
|
||||
int index = 0;
|
||||
foreach (var di in displayListGE)
|
||||
{
|
||||
di.ListIndex = index++;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Only call once per lifetime
|
||||
/// </summary>
|
||||
public void InitialDisplay(List<LibraryBook> dbBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
GridEntries = new GridEntryCollection(CreateGridEntries(dbBooks));
|
||||
GridEntries.CollapseAll();
|
||||
|
||||
InitialLoaded?.Invoke(this, EventArgs.Empty);
|
||||
VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count());
|
||||
|
||||
RegisterCollectionChanged();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error displaying library in {0}", nameof(ProductsDisplayViewModel));
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Call when there's been a change to the library
|
||||
/// </summary>
|
||||
public async Task DisplayBooks(List<LibraryBook> dbBooks)
|
||||
public async Task DisplayBooksAsync(List<LibraryBook> dbBooks)
|
||||
{
|
||||
try
|
||||
{
|
||||
//List is already displayed. Replace all items with new ones, refilter, and re-sort
|
||||
string existingFilter = GridEntries?.Filter;
|
||||
var newEntries = CreateGridEntries(dbBooks);
|
||||
var existingSeriesEntries = SOURCE.SeriesEntries().ToList();
|
||||
|
||||
var existingSeriesEntries = GridEntries.AllItems().SeriesEntries().ToList();
|
||||
SOURCE.Clear();
|
||||
SOURCE.AddRange(CreateGridEntries(dbBooks));
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
//If replacing the list, preserve user's existing collapse/expand
|
||||
//state. When resetting a list, default state is cosed.
|
||||
foreach (var series in existingSeriesEntries)
|
||||
{
|
||||
GridEntries.ReplaceList(newEntries);
|
||||
var sEntry = SOURCE.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
|
||||
if (sEntry is SeriesEntry se)
|
||||
se.Liberate.Expanded = series.Liberate.Expanded;
|
||||
}
|
||||
|
||||
//We're replacing the list, so preserve usere's existing collapse/expand
|
||||
//state. When resetting a list, default state is open.
|
||||
foreach (var series in existingSeriesEntries)
|
||||
{
|
||||
var sEntry = GridEntries.InternalList.FirstOrDefault(ge => ge.AudibleProductId == series.AudibleProductId);
|
||||
if (sEntry is SeriesEntry se && !series.Liberate.Expanded)
|
||||
GridEntries.CollapseItem(se);
|
||||
}
|
||||
//Run query on new list
|
||||
FilteredInGridEntries = QueryResults(SOURCE, FilterString);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
|
||||
|
||||
GridEntries.Filter = existingFilter;
|
||||
ReSort();
|
||||
VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count());
|
||||
});
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -135,7 +83,7 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private static IEnumerable<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> dbBooks)
|
||||
private static List<GridEntry> CreateGridEntries(IEnumerable<LibraryBook> dbBooks)
|
||||
{
|
||||
var geList = dbBooks
|
||||
.Where(lb => lb.Book.IsProduct())
|
||||
@@ -145,9 +93,7 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild());
|
||||
|
||||
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
|
||||
|
||||
foreach (var parent in seriesBooks)
|
||||
foreach (var parent in dbBooks.Where(lb => lb.Book.IsEpisodeParent()))
|
||||
{
|
||||
var seriesEpisodes = episodes.FindChildren(parent);
|
||||
|
||||
@@ -158,72 +104,67 @@ namespace LibationAvalonia.ViewModels
|
||||
geList.Add(seriesEntry);
|
||||
geList.AddRange(seriesEntry.Children);
|
||||
}
|
||||
return geList.OrderByDescending(e => e.DateAdded);
|
||||
|
||||
var bookList = geList.OrderByDescending(e => e.DateAdded).ToList();
|
||||
|
||||
//ListIndex is used by RowComparer to make column sort stable
|
||||
int index = 0;
|
||||
foreach (GridEntry di in bookList)
|
||||
di.ListIndex = index++;
|
||||
|
||||
return bookList;
|
||||
}
|
||||
|
||||
public void ToggleSeriesExpanded(SeriesEntry seriesEntry)
|
||||
{
|
||||
if (seriesEntry.Liberate.Expanded)
|
||||
GridEntries.CollapseItem(seriesEntry);
|
||||
else
|
||||
GridEntries.ExpandItem(seriesEntry);
|
||||
|
||||
VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count());
|
||||
seriesEntry.Liberate.Expanded = !seriesEntry.Liberate.Expanded;
|
||||
GridEntries.Refresh();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Filtering
|
||||
|
||||
public async Task Filter(string searchString)
|
||||
{
|
||||
await Dispatcher.UIThread.InvokeAsync(() =>
|
||||
{
|
||||
int visibleCount = GridEntries.Count;
|
||||
if (searchString == FilterString)
|
||||
return;
|
||||
|
||||
if (string.IsNullOrEmpty(searchString))
|
||||
GridEntries.RemoveFilter();
|
||||
else
|
||||
GridEntries.Filter = searchString;
|
||||
FilterString = searchString;
|
||||
|
||||
if (visibleCount != GridEntries.Count)
|
||||
VisibleCountChanged?.Invoke(this, GridEntries.BookEntries().Count());
|
||||
if (SOURCE.Count == 0)
|
||||
return;
|
||||
|
||||
//Re-sort after filtering
|
||||
ReSort();
|
||||
});
|
||||
FilteredInGridEntries = QueryResults(SOURCE, searchString);
|
||||
|
||||
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Sorting
|
||||
|
||||
public void Sort(DataGridColumn sortColumn)
|
||||
private bool CollectionFilter(object item)
|
||||
{
|
||||
//Force the comparer to get the current sort order. We can't
|
||||
//retrieve it from inside this event handler because Avalonia
|
||||
//doesn't set the property until after this event.
|
||||
var comparer = sortColumn.CustomSortComparer as RowComparer;
|
||||
comparer.SortDirection = null;
|
||||
if (item is LibraryBookEntry lbe
|
||||
&& lbe.IsEpisode
|
||||
&& lbe.Parent?.Liberate?.Expanded != true)
|
||||
return false;
|
||||
|
||||
_currentSortColumn = sortColumn;
|
||||
if (FilteredInGridEntries is null) return true;
|
||||
|
||||
return FilteredInGridEntries.Contains(item);
|
||||
}
|
||||
|
||||
//Must be invoked on UI thread
|
||||
private void ReSort()
|
||||
private static List<GridEntry> QueryResults(List<GridEntry> entries, string searchString)
|
||||
{
|
||||
if (_currentSortColumn is null)
|
||||
{
|
||||
//Sort ascending and reverse. That's how the comparer is designed to work to be compatible with Avalonia.
|
||||
var defaultComparer = new RowComparer(ListSortDirection.Descending, nameof(GridEntry.DateAdded));
|
||||
GridEntries.InternalList.Sort(defaultComparer);
|
||||
GridEntries.InternalList.Reverse();
|
||||
GridEntries.ResetCollection();
|
||||
}
|
||||
else
|
||||
{
|
||||
_currentSortColumn.Sort(((RowComparer)_currentSortColumn.CustomSortComparer).SortDirection ?? ListSortDirection.Ascending);
|
||||
}
|
||||
}
|
||||
if (string.IsNullOrEmpty(searchString)) return null;
|
||||
|
||||
var SearchResults = SearchEngineCommands.Search(searchString);
|
||||
|
||||
var booksFilteredIn = entries.BookEntries().Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => (GridEntry)lbe);
|
||||
|
||||
//Find all series containing children that match the search criteria
|
||||
var seriesFilteredIn = entries.SeriesEntries().Where(s => s.Children.Join(SearchResults.Docs, lbe => lbe.AudibleProductId, d => d.ProductId, (lbe, d) => lbe).Any());
|
||||
|
||||
return booksFilteredIn.Concat(seriesFilteredIn).ToList();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -231,8 +172,8 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public void DoneRemovingBooks()
|
||||
{
|
||||
foreach (var item in GridEntries.AllItems())
|
||||
item.PropertyChanged -= Item_PropertyChanged;
|
||||
foreach (var item in SOURCE)
|
||||
item.PropertyChanged -= GridEntry_PropertyChanged;
|
||||
RemoveColumnVisivle = false;
|
||||
}
|
||||
|
||||
@@ -247,49 +188,47 @@ namespace LibationAvalonia.ViewModels
|
||||
var result = await MessageBox.ShowConfirmationDialog(
|
||||
null,
|
||||
libraryBooks,
|
||||
// do not use `$` string interpolation. See impl.
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
// do not use `$` string interpolation. See impl.
|
||||
"Are you sure you want to remove {0} from Libation's library?",
|
||||
"Remove books from Libation?");
|
||||
|
||||
if (result != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
foreach (var book in selectedBooks)
|
||||
book.PropertyChanged -= Item_PropertyChanged;
|
||||
book.PropertyChanged -= GridEntry_PropertyChanged;
|
||||
|
||||
var idsToRemove = libraryBooks.Select(lb => lb.Book.AudibleProductId).ToList();
|
||||
|
||||
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
||||
return;
|
||||
|
||||
//After DisplayBooks() re-creates the list,
|
||||
//re-subscribe to all items' PropertyChanged events.
|
||||
|
||||
foreach (var b in GetAllBookEntries())
|
||||
b.PropertyChanged += GridEntry_PropertyChanged;
|
||||
|
||||
GridEntries.CollectionChanged -= BindingList_CollectionChanged;
|
||||
}
|
||||
|
||||
GridEntries.CollectionChanged += BindingList_CollectionChanged;
|
||||
|
||||
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
|
||||
//so there's no need to remove books from the grid display here.
|
||||
var removeLibraryBooks = await LibraryCommands.RemoveBooksAsync(idsToRemove);
|
||||
|
||||
foreach (var b in GetAllBookEntries())
|
||||
b.Remove = false;
|
||||
|
||||
RemovableCountChanged?.Invoke(this, 0);
|
||||
}
|
||||
|
||||
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
|
||||
{
|
||||
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
|
||||
return;
|
||||
|
||||
//After ProductsDisplay2.Display() re-creates the list,
|
||||
//re-subscribe to all items' PropertyChanged events.
|
||||
|
||||
foreach (var b in GetAllBookEntries())
|
||||
b.PropertyChanged += Item_PropertyChanged;
|
||||
|
||||
GridEntries.CollectionChanged -= BindingList_CollectionChanged;
|
||||
}
|
||||
|
||||
public async Task ScanAndRemoveBooksAsync(params Account[] accounts)
|
||||
{
|
||||
foreach (var item in GridEntries.AllItems())
|
||||
foreach (var item in SOURCE)
|
||||
{
|
||||
item.Remove = false;
|
||||
item.PropertyChanged += Item_PropertyChanged;
|
||||
item.PropertyChanged += GridEntry_PropertyChanged;
|
||||
}
|
||||
|
||||
RemoveColumnVisivle = true;
|
||||
@@ -302,9 +241,6 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
var allBooks = GetAllBookEntries();
|
||||
|
||||
foreach (var b in allBooks)
|
||||
b.Remove = false;
|
||||
|
||||
var lib = allBooks
|
||||
.Select(lbe => lbe.LibraryBook)
|
||||
.Where(lb => !lb.Book.HasLiberated());
|
||||
@@ -326,7 +262,7 @@ namespace LibationAvalonia.ViewModels
|
||||
}
|
||||
}
|
||||
|
||||
private void Item_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName == nameof(GridEntry.Remove) && sender is LibraryBookEntry lbEntry)
|
||||
{
|
||||
|
||||
@@ -14,12 +14,6 @@ namespace LibationAvalonia.ViewModels
|
||||
public static IEnumerable<SeriesEntry> SeriesEntries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.OfType<SeriesEntry>();
|
||||
|
||||
public static T? FindByAsin<T>(this IEnumerable<T> gridEntries, string audibleProductID) where T : GridEntry
|
||||
=> gridEntries.FirstOrDefault(i => i.AudibleProductId == audibleProductID);
|
||||
|
||||
public static IEnumerable<SeriesEntry> EmptySeries(this IEnumerable<GridEntry> gridEntries)
|
||||
=> gridEntries.SeriesEntries().Where(i => i.Children.Count == 0);
|
||||
|
||||
public static SeriesEntry? FindSeriesParent(this IEnumerable<GridEntry> gridEntries, LibraryBook seriesEpisode)
|
||||
{
|
||||
if (seriesEpisode.Book.SeriesLink is null) return null;
|
||||
|
||||
@@ -13,25 +13,19 @@ namespace LibationAvalonia.ViewModels
|
||||
/// sorted by series index, ascending. Stable sorting is achieved by comparing the GridEntry.ListIndex
|
||||
/// properties when 2 items compare equal.
|
||||
/// </summary>
|
||||
internal class RowComparer : IComparer, IComparer<GridEntry>
|
||||
internal class RowComparer : IComparer, IComparer<GridEntry>, IComparer<object>
|
||||
{
|
||||
private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance);
|
||||
|
||||
public DataGridColumn Column { get; init; }
|
||||
public string PropertyName { get; private set; }
|
||||
public ListSortDirection? SortDirection { get; set; }
|
||||
|
||||
public RowComparer(DataGridColumn column)
|
||||
{
|
||||
Column = column;
|
||||
PropertyName = Column.SortMemberPath;
|
||||
}
|
||||
public RowComparer(ListSortDirection direction, string propertyName)
|
||||
{
|
||||
SortDirection = direction;
|
||||
PropertyName = propertyName;
|
||||
}
|
||||
|
||||
public int Compare(object x, object y)
|
||||
{
|
||||
@@ -42,7 +36,7 @@ namespace LibationAvalonia.ViewModels
|
||||
var geA = (GridEntry)x;
|
||||
var geB = (GridEntry)y;
|
||||
|
||||
SortDirection ??= GetSortOrder();
|
||||
var sortDirection = GetSortOrder();
|
||||
|
||||
SeriesEntry parentA = null;
|
||||
SeriesEntry parentB = null;
|
||||
@@ -54,16 +48,16 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
//both a and b are top-level grid entries
|
||||
if (parentA is null && parentB is null)
|
||||
return InternalCompare(geA, geB);
|
||||
return InternalCompare(geA, geB, sortDirection);
|
||||
|
||||
//a is top-level, b is a child
|
||||
if (parentA is null && parentB is not null)
|
||||
{
|
||||
// b is a child of a, parent is always first
|
||||
if (parentB == geA)
|
||||
return SortDirection is ListSortDirection.Ascending ? -1 : 1;
|
||||
return sortDirection is ListSortDirection.Ascending ? -1 : 1;
|
||||
else
|
||||
return InternalCompare(geA, parentB);
|
||||
return InternalCompare(geA, parentB, sortDirection);
|
||||
}
|
||||
|
||||
//a is a child, b is a top-level
|
||||
@@ -71,24 +65,24 @@ namespace LibationAvalonia.ViewModels
|
||||
{
|
||||
// a is a child of b, parent is always first
|
||||
if (parentA == geB)
|
||||
return SortDirection is ListSortDirection.Ascending ? 1 : -1;
|
||||
return sortDirection is ListSortDirection.Ascending ? 1 : -1;
|
||||
else
|
||||
return InternalCompare(parentA, geB);
|
||||
return InternalCompare(parentA, geB, sortDirection);
|
||||
}
|
||||
|
||||
//both are children of the same series, always present in order of series index, ascending
|
||||
if (parentA == parentB)
|
||||
return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (SortDirection is ListSortDirection.Ascending ? 1 : -1);
|
||||
return geA.SeriesIndex.CompareTo(geB.SeriesIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
|
||||
|
||||
//a and b are children of different series.
|
||||
return InternalCompare(parentA, parentB);
|
||||
return InternalCompare(parentA, parentB, sortDirection);
|
||||
}
|
||||
|
||||
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection
|
||||
private ListSortDirection? GetSortOrder()
|
||||
=> CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) as ListSortDirection?;
|
||||
|
||||
private int InternalCompare(GridEntry x, GridEntry y)
|
||||
private int InternalCompare(GridEntry x, GridEntry y, ListSortDirection? sortDirection)
|
||||
{
|
||||
var val1 = x.GetMemberValue(PropertyName);
|
||||
var val2 = y.GetMemberValue(PropertyName);
|
||||
@@ -98,7 +92,7 @@ namespace LibationAvalonia.ViewModels
|
||||
//If items compare equal, compare them by their positions in the the list.
|
||||
//This is how you achieve a stable sort.
|
||||
if (compareResult == 0)
|
||||
return x.ListIndex.CompareTo(y.ListIndex);
|
||||
return x.ListIndex.CompareTo(y.ListIndex) * (sortDirection is ListSortDirection.Ascending ? 1 : -1);
|
||||
else
|
||||
return compareResult;
|
||||
}
|
||||
|
||||
@@ -50,12 +50,13 @@ namespace LibationAvalonia.ViewModels
|
||||
public override bool IsSeries => true;
|
||||
public override bool IsEpisode => false;
|
||||
public override bool IsBook => false;
|
||||
public override double Opacity => 1;
|
||||
|
||||
#endregion
|
||||
|
||||
public SeriesEntry(LibraryBook parent, IEnumerable<LibraryBook> children)
|
||||
{
|
||||
Liberate = new LiberateButtonStatus(IsSeries) { Expanded = true };
|
||||
Liberate = new LiberateButtonStatus(IsSeries);
|
||||
SeriesIndex = -1;
|
||||
LibraryBook = parent;
|
||||
|
||||
|
||||
62
Source/LibationAvalonia/Views/MainWindow.Export.cs
Normal file
62
Source/LibationAvalonia/Views/MainWindow.Export.cs
Normal file
@@ -0,0 +1,62 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Views
|
||||
{
|
||||
//DONE
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void Configure_Export() { }
|
||||
|
||||
public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var options = new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Where to export Library",
|
||||
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books),
|
||||
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}.xlsx",
|
||||
DefaultExtension = "xlsx",
|
||||
ShowOverwritePrompt = true,
|
||||
FileTypeChoices = new FilePickerFileType[]
|
||||
{
|
||||
new("Excel Workbook (*.xlsx)") { Patterns = new[] { "xlsx" } },
|
||||
new("CSV files (*.csv)") { Patterns = new[] { "csv" } },
|
||||
new("JSON files (*.json)") { Patterns = new[] { "json" } },
|
||||
new("All files (*.*)") { Patterns = new[] { "*" } },
|
||||
}
|
||||
};
|
||||
|
||||
var selectedFile = await StorageProvider.SaveFilePickerAsync(options);
|
||||
|
||||
if (!selectedFile.TryGetUri(out var uri)) return;
|
||||
|
||||
var ext = System.IO.Path.GetExtension(uri.LocalPath);
|
||||
switch (ext)
|
||||
{
|
||||
case "xlsx": // xlsx
|
||||
default:
|
||||
LibraryExporter.ToXlsx(uri.LocalPath);
|
||||
break;
|
||||
case "csv": // csv
|
||||
LibraryExporter.ToCsv(uri.LocalPath);
|
||||
break;
|
||||
case "json": // json
|
||||
LibraryExporter.ToJson(uri.LocalPath);
|
||||
break;
|
||||
}
|
||||
|
||||
await MessageBox.Show("Library exported to:\r\n" + uri.LocalPath, "Library Exported");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -60,11 +60,5 @@ namespace LibationAvalonia.Views
|
||||
|
||||
public async void editQuickFiltersToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await new Dialogs.EditQuickFilters().ShowDialog(this);
|
||||
|
||||
public async void ProductsDisplay_Initialized(object sender, EventArgs e)
|
||||
{
|
||||
if (QuickFilters.UseDefault)
|
||||
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@
|
||||
Name="Form1"
|
||||
Icon="/Assets/libation.ico">
|
||||
|
||||
<Border BorderBrush="{DynamicResource DataGridGridLinesBrush}" BorderThickness="2" Padding="15">
|
||||
<Border BorderBrush="{DynamicResource DataGridGridLinesBrush}" BorderThickness="2" Padding="10,0,10,10">
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto">
|
||||
<Grid Grid.Row="0" ColumnDefinitions="1*,Auto">
|
||||
|
||||
@@ -143,23 +143,30 @@
|
||||
<Style Selector="TextBox">
|
||||
<Setter Property="MinHeight" Value="10" />
|
||||
</Style>
|
||||
<Style Selector="Button">
|
||||
<Setter Property="Padding" Value="15,0,15,0" />
|
||||
<Setter Property="Margin" Value="10,0,0,0" />
|
||||
<Setter Property="Height" Value="30" />
|
||||
<Setter Property="HorizontalContentAlignment" Value="Center" />
|
||||
<Setter Property="VerticalContentAlignment" Value="Center" />
|
||||
</Style>
|
||||
</Grid.Styles>
|
||||
|
||||
<StackPanel Grid.Column="0" Orientation="Horizontal">
|
||||
<Button Click="filterHelpBtn_Click" Height="30" Width="30" Content="?"/>
|
||||
<Button Click="addQuickFilterBtn_Click" Height="30" Width="150" Margin="10,0,10,0" Content="Add To Quick Filters"/>
|
||||
<Button Margin="0" Click="filterHelpBtn_Click" Content="?"/>
|
||||
<Button Click="addQuickFilterBtn_Click" Content="Add To Quick Filters"/>
|
||||
</StackPanel>
|
||||
|
||||
<StackPanel Grid.Column="1" Orientation="Horizontal">
|
||||
<Button IsVisible="{Binding RemoveButtonsVisible}" IsEnabled="{Binding RemoveBooksButtonEnabled}" Click="removeBooksBtn_Click" Height="30" Width="220" Content="{Binding RemoveBooksButtonText}"/>
|
||||
<Button IsVisible="{Binding RemoveButtonsVisible}" Click="doneRemovingBtn_Click" Height="30" Width="160" Margin="10,0,0,0" Content="Done Removing Books"/>
|
||||
<Button IsVisible="{Binding RemoveButtonsVisible}" IsEnabled="{Binding RemoveBooksButtonEnabled}" Click="removeBooksBtn_Click" Content="{Binding RemoveBooksButtonText}"/>
|
||||
<Button IsVisible="{Binding RemoveButtonsVisible}" Click="doneRemovingBtn_Click" Content="Done Removing Books"/>
|
||||
</StackPanel>
|
||||
|
||||
<TextBox Grid.Column="1" IsVisible="{Binding !RemoveButtonsVisible}" Text="{Binding FilterString, Mode=TwoWay}" KeyDown="filterSearchTb_KeyPress" />
|
||||
<TextBox Grid.Column="1" Margin="10,0,0,0" IsVisible="{Binding !RemoveButtonsVisible}" Text="{Binding FilterString, Mode=TwoWay}" KeyDown="filterSearchTb_KeyPress" />
|
||||
|
||||
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
|
||||
<Button Click="filterBtn_Click" Height="30" Width="80" Margin="10,0,10,0" Content="Filter"/>
|
||||
<Button Click="ToggleQueueHideBtn_Click" Height="30" Width="30" Content="{Binding QueueHideButtonText}"/>
|
||||
<Button Click="filterBtn_Click" Height="30" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
|
||||
<Button Padding="5,0,5,0" Click="ToggleQueueHideBtn_Click" Content="{Binding QueueHideButtonText}"/>
|
||||
</StackPanel>
|
||||
|
||||
</Grid>
|
||||
@@ -174,7 +181,6 @@
|
||||
<!-- Product Display Grid -->
|
||||
<views:ProductsDisplay
|
||||
Name="productsDisplay"
|
||||
Initialized="ProductsDisplay_Initialized1"
|
||||
DataContext="{Binding ProductsDisplay}"
|
||||
LiberateClicked="ProductsDisplay_LiberateClicked"/>
|
||||
</SplitView>
|
||||
@@ -10,6 +10,8 @@ using DataLayer;
|
||||
using System.Collections.Generic;
|
||||
using System.Threading.Tasks;
|
||||
using AppScaffolding;
|
||||
using System.Linq;
|
||||
using LibationAvalonia.Dialogs;
|
||||
|
||||
namespace LibationAvalonia.Views
|
||||
{
|
||||
@@ -46,14 +48,13 @@ namespace LibationAvalonia.Views
|
||||
// misc which belongs in winforms app but doesn't have a UI element
|
||||
Configure_NonUI();
|
||||
|
||||
_viewModel.ProductsDisplay.InitialLoaded += ProductsDisplay_Initialized;
|
||||
_viewModel.ProductsDisplay.RemovableCountChanged += ProductsDisplay_RemovableCountChanged;
|
||||
_viewModel.ProductsDisplay.VisibleCountChanged += ProductsDisplay_VisibleCountChanged;
|
||||
|
||||
{
|
||||
this.LibraryLoaded += MainWindow_LibraryLoaded;
|
||||
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooks(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
LibraryCommands.LibrarySizeChanged += async (_, _) => await _viewModel.ProductsDisplay.DisplayBooksAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
Closing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
|
||||
}
|
||||
Opened += MainWindow_Opened;
|
||||
@@ -172,15 +173,12 @@ namespace LibationAvalonia.Views
|
||||
Environment.Exit(0);
|
||||
}
|
||||
|
||||
public void ProductsDisplay_Initialized1(object sender, EventArgs e)
|
||||
private async void MainWindow_LibraryLoaded(object sender, List<LibraryBook> dbBooks)
|
||||
{
|
||||
if (sender is ProductsDisplay products)
|
||||
_viewModel.ProductsDisplay.RegisterCollectionChanged(products);
|
||||
}
|
||||
if (QuickFilters.UseDefault)
|
||||
await performFilter(QuickFilters.Filters.FirstOrDefault());
|
||||
|
||||
private void MainWindow_LibraryLoaded(object sender, List<LibraryBook> dbBooks)
|
||||
{
|
||||
_viewModel.ProductsDisplay.InitialDisplay(dbBooks);
|
||||
await _viewModel.ProductsDisplay.DisplayBooksAsync(dbBooks);
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
@@ -196,10 +194,5 @@ namespace LibationAvalonia.Views
|
||||
quickFiltersToolStripMenuItem = this.FindControl<MenuItem>(nameof(quickFiltersToolStripMenuItem));
|
||||
productsDisplay = this.FindControl<ProductsDisplay>(nameof(productsDisplay));
|
||||
}
|
||||
|
||||
protected override void OnDataContextChanged(EventArgs e)
|
||||
{
|
||||
base.OnDataContextChanged(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Controls;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Views
|
||||
{
|
||||
//DONE
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void Configure_Export() { }
|
||||
|
||||
public async void exportLibraryToolStripMenuItem_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
try
|
||||
{
|
||||
var saveFileDialog = new SaveFileDialog
|
||||
{
|
||||
Title = "Where to export Library",
|
||||
};
|
||||
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "Excel Workbook (*.xlsx)", Extensions = new() { "xlsx" } });
|
||||
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "CSV files (*.csv)", Extensions = new() { "csv" } });
|
||||
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "JSON files (*.json)", Extensions = new() { "json" } });
|
||||
saveFileDialog.Filters.Add(new FileDialogFilter { Name = "All files (*.*)", Extensions = new() { "*" } });
|
||||
|
||||
var fileName = await saveFileDialog.ShowAsync(this);
|
||||
if (fileName is null) return;
|
||||
|
||||
var ext = System.IO.Path.GetExtension(fileName);
|
||||
switch (ext)
|
||||
{
|
||||
case "xlsx": // xlsx
|
||||
default:
|
||||
LibraryExporter.ToXlsx(fileName);
|
||||
break;
|
||||
case "csv": // csv
|
||||
LibraryExporter.ToCsv(fileName);
|
||||
break;
|
||||
case "json": // json
|
||||
LibraryExporter.ToJson(fileName);
|
||||
break;
|
||||
}
|
||||
|
||||
await MessageBox.Show("Library exported to:\r\n" + fileName, "Library Exported");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(this, "Error attempting to export your library.", "Error exporting", ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,193 +10,188 @@
|
||||
<Grid>
|
||||
|
||||
<DataGrid
|
||||
CopyingRowClipboardContent="DataGrid_CopyToClipboard"
|
||||
Name="productsGrid"
|
||||
ClipboardCopyMode="IncludeHeader"
|
||||
GridLinesVisibility="All"
|
||||
AutoGenerateColumns="False"
|
||||
Items="{Binding GridEntries}"
|
||||
Sorting="ProductsGrid_Sorting"
|
||||
CanUserSortColumns="True"
|
||||
CanUserSortColumns="True" BorderThickness="3"
|
||||
CanUserReorderColumns="True">
|
||||
|
||||
<DataGrid.Styles>
|
||||
<Style Selector="DataGridCell > Panel">
|
||||
<Setter Property="Margin" Value="0,1,0,1"/>
|
||||
<Setter Property="Height" Value="80"/>
|
||||
</Style>
|
||||
<Style Selector="DataGridCell > Panel > TextBlock">
|
||||
<Setter Property="VerticalAlignment" Value="Center"/>
|
||||
<Setter Property="HorizontalAlignment" Value="Stretch"/>
|
||||
<Setter Property="TextWrapping" Value="Wrap"/>
|
||||
<Setter Property="Padding" Value="4"/>
|
||||
</Style>
|
||||
</DataGrid.Styles>
|
||||
|
||||
<DataGrid.Columns>
|
||||
|
||||
<controls:DataGridCheckBoxColumnExt
|
||||
PropertyChanged="RemoveColumn_PropertyChanged"
|
||||
IsVisible="{Binding RemoveColumnVisivle}"
|
||||
Header="Remove"
|
||||
IsThreeState="True"
|
||||
IsReadOnly="False"
|
||||
<DataGridTemplateColumn
|
||||
CanUserSort="True"
|
||||
Binding="{Binding Remove, Mode=TwoWay}"
|
||||
Width="70" SortMemberPath="Remove" />
|
||||
IsVisible="{Binding RemoveColumnVisivle}"
|
||||
PropertyChanged="RemoveColumn_PropertyChanged"
|
||||
Header="Remove"
|
||||
IsReadOnly="False"
|
||||
SortMemberPath="Remove"
|
||||
Width="75">
|
||||
|
||||
<DataGridTemplateColumn CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button Width="75" Height="80" Click="LiberateButton_Click" ToolTip.Tip="{Binding Liberate.ToolTip}">
|
||||
<Image Stretch="None" Height="80" Source="{Binding Liberate.Image}" />
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<CheckBox
|
||||
HorizontalAlignment="Center"
|
||||
IsThreeState="True"
|
||||
IsChecked="{Binding Remove, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover">
|
||||
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Image Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
|
||||
<Button Opacity="{Binding Opacity}" Padding="0" HorizontalAlignment="Stretch" VerticalAlignment="Stretch" Click="LiberateButton_Click" ToolTip.Tip="{Binding Liberate.ToolTip}">
|
||||
<Image Source="{Binding Liberate.Image}" Stretch="None" />
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Image Opacity="{Binding Opacity}" Tapped="Cover_Click" Height="80" Source="{Binding Cover}" ToolTip.Tip="Click to see full size" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
|
||||
<DataGridTemplateColumn MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title">
|
||||
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border Background="{Binding BackgroundBrush}" BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Title}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Title}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors">
|
||||
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Authors}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Authors}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators">
|
||||
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Narrators}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Narrators}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length">
|
||||
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Length}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Length}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series">
|
||||
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Series}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Series}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description">
|
||||
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock Tapped="Description_Click" VerticalAlignment="Center" TextWrapping="Wrap" ToolTip.Tip="Click to see full description" Text="{Binding Description}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
|
||||
<TextBlock Text="{Binding Description}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category">
|
||||
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding Category}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Category}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="120" Header="Product
Rating" CanUserSort="True" SortMemberPath="ProductRating">
|
||||
<controls:DataGridTemplateColumnExt Width="120" Header="Product
Rating" CanUserSort="True" SortMemberPath="ProductRating" ClipboardContentBinding="{Binding ProductRating}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" FontSize="11" Text="{Binding ProductRating}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding ProductRating}" TextWrapping="NoWrap" FontSize="11" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="90" Header="Purchase
Date" CanUserSort="True" SortMemberPath="PurchaseDate">
|
||||
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase
Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" Text="{Binding PurchaseDate}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding PurchaseDate}" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="120" Header="My Rating" CanUserSort="True" SortMemberPath="MyRating">
|
||||
<controls:DataGridTemplateColumnExt Width="120" Header="My Rating" CanUserSort="True" SortMemberPath="MyRating" ClipboardContentBinding="{Binding MyRating}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="Wrap" FontSize="11" Text="{Binding MyRating}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding MyRating}" TextWrapping="NoWrap" FontSize="11" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc">
|
||||
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<Panel Background="{Binding BackgroundBrush}">
|
||||
<Border BorderThickness="3" Height="80">
|
||||
<TextBlock VerticalAlignment="Center" TextWrapping="WrapWithOverflow" FontSize="10" Text="{Binding Misc}" />
|
||||
</Border>
|
||||
<Panel Background="{Binding BackgroundBrush}" Opacity="{Binding Opacity}">
|
||||
<TextBlock Text="{Binding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
|
||||
</Panel>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
<DataGridTemplateColumn CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags">
|
||||
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags.Tags}">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<StackPanel Orientation="Horizontal">
|
||||
<Button IsVisible="{Binding !IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
|
||||
<Panel>
|
||||
<Image IsVisible="{Binding !BookTags.HasTags}" Stretch="None" Source="/Assets/edit_25x25.png" />
|
||||
<TextBlock IsVisible="{Binding BookTags.HasTags}" FontSize="12" TextWrapping="WrapWithOverflow" Text="{Binding BookTags.Tags}"/>
|
||||
</Panel>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
<Button IsVisible="{Binding !IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
|
||||
<Panel>
|
||||
<Image IsVisible="{Binding !BookTags.HasTags}" Stretch="None" Source="/Assets/edit_25x25.png" />
|
||||
<TextBlock IsVisible="{Binding BookTags.HasTags}" FontSize="12" TextWrapping="WrapWithOverflow" Text="{Binding BookTags.Tags}"/>
|
||||
</Panel>
|
||||
</Button>
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
</controls:DataGridTemplateColumnExt>
|
||||
|
||||
</DataGrid.Columns>
|
||||
</DataGrid>
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using DataLayer;
|
||||
using FileLiberator;
|
||||
using LibationFileManager;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using ApplicationServices;
|
||||
using Avalonia;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Markup.Xaml;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DataLayer;
|
||||
using FileLiberator;
|
||||
using LibationAvalonia.Controls;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace LibationAvalonia.Views
|
||||
{
|
||||
@@ -27,31 +30,32 @@ namespace LibationAvalonia.Views
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
List<GridEntry> sampleEntries = new()
|
||||
List<LibraryBook> sampleEntries = new()
|
||||
{
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0")),
|
||||
new LibraryBookEntry(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")),
|
||||
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
|
||||
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
|
||||
};
|
||||
DataContext = new ProductsDisplayViewModel(sampleEntries);
|
||||
|
||||
var pdvm = new ProductsDisplayViewModel();
|
||||
_ = pdvm.DisplayBooksAsync(sampleEntries);
|
||||
DataContext = pdvm;
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
Configure_ColumnCustomization();
|
||||
|
||||
foreach (var column in productsGrid.Columns)
|
||||
{
|
||||
column.CustomSortComparer = new RowComparer(column);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProductsGrid_Sorting(object sender, DataGridColumnEventArgs e)
|
||||
{
|
||||
_viewModel.Sort(e.Column);
|
||||
}
|
||||
}
|
||||
|
||||
private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
|
||||
{
|
||||
@@ -62,11 +66,6 @@ namespace LibationAvalonia.Views
|
||||
}
|
||||
}
|
||||
|
||||
public void DataGrid_CopyToClipboard(object sender, DataGridRowClipboardEventArgs e)
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
@@ -74,6 +73,87 @@ namespace LibationAvalonia.Views
|
||||
productsGrid = this.FindControl<DataGrid>(nameof(productsGrid));
|
||||
}
|
||||
|
||||
#region Cell Context Menu
|
||||
|
||||
public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args)
|
||||
{
|
||||
// stop light
|
||||
if (args.Column.SortMemberPath == "Liberate")
|
||||
{
|
||||
var entry = args.GridEntry;
|
||||
|
||||
if (entry.IsSeries)
|
||||
return;
|
||||
|
||||
var setDownloadMenuItem = new MenuItem()
|
||||
{
|
||||
Header = "Set Download status to '_Downloaded'",
|
||||
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated
|
||||
};
|
||||
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
|
||||
var setNotDownloadMenuItem = new MenuItem()
|
||||
{
|
||||
Header = "Set Download status to '_Not Downloaded'",
|
||||
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated
|
||||
};
|
||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
|
||||
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
|
||||
removeMenuItem.Click += (_, __) => LibraryCommands.RemoveBook(entry.AudibleProductId);
|
||||
|
||||
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
|
||||
locateFileMenuItem.Click += async (_, __) =>
|
||||
{
|
||||
try
|
||||
{
|
||||
var openFileDialogOptions = new FilePickerOpenOptions
|
||||
{
|
||||
Title = $"Locate the audio file for '{entry.Book.Title}'",
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = new Avalonia.Platform.Storage.FileIO.BclStorageFolder(Configuration.Instance.Books),
|
||||
FileTypeFilter = new FilePickerFileType[]
|
||||
{
|
||||
new("All files (*.*)") { Patterns = new[] { "*" } },
|
||||
}
|
||||
};
|
||||
|
||||
var selectedFiles = await this.GetParentWindow().StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
|
||||
var selectedFile = selectedFiles.SingleOrDefault();
|
||||
|
||||
if (selectedFile.TryGetUri(out var uri))
|
||||
FilePathCache.Insert(entry.AudibleProductId, uri.LocalPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = "Error saving book's location";
|
||||
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
|
||||
}
|
||||
};
|
||||
|
||||
args.ContextMenuItems.AddRange(new[]
|
||||
{
|
||||
setDownloadMenuItem,
|
||||
setNotDownloadMenuItem,
|
||||
removeMenuItem,
|
||||
locateFileMenuItem
|
||||
});
|
||||
}
|
||||
else
|
||||
{
|
||||
// any non-stop light column
|
||||
// (except for the Cover column which does not have a context menu)
|
||||
var menuItem = new MenuItem { Header = "_Copy Cell Contents" };
|
||||
|
||||
menuItem.Click += async (s, e)
|
||||
=> await Application.Current.Clipboard.SetTextAsync(args.CellClipboardContents);
|
||||
|
||||
args.ContextMenuItems.Add(menuItem);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Column Customizations
|
||||
|
||||
private void Configure_ColumnCustomization()
|
||||
@@ -99,6 +179,10 @@ namespace LibationAvalonia.Views
|
||||
|
||||
foreach (var column in productsGrid.Columns)
|
||||
{
|
||||
//Wire up column context menu
|
||||
if (column is DataGridTemplateColumnExt tc)
|
||||
tc.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded;
|
||||
|
||||
var itemName = column.SortMemberPath;
|
||||
|
||||
if (itemName == nameof(GridEntry.Remove))
|
||||
@@ -198,7 +282,7 @@ namespace LibationAvalonia.Views
|
||||
|
||||
//Expanding and collapsing reset the list, which will cause focus to shift
|
||||
//to the topright cell. Reset focus onto the clicked button's cell.
|
||||
((sender as Control).Parent.Parent as DataGridCell)?.Focus();
|
||||
(sender as Button).Parent?.Focus();
|
||||
}
|
||||
else if (button.DataContext is LibraryBookEntry lbEntry)
|
||||
{
|
||||
@@ -212,12 +296,11 @@ namespace LibationAvalonia.Views
|
||||
imageDisplayDialog.Close();
|
||||
}
|
||||
|
||||
public void Cover_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
public void Cover_Click(object sender, Avalonia.Input.TappedEventArgs args)
|
||||
{
|
||||
if (sender is not Image tblock || tblock.DataContext is not GridEntry gEntry)
|
||||
return;
|
||||
|
||||
|
||||
if (imageDisplayDialog is null || !imageDisplayDialog.IsVisible)
|
||||
{
|
||||
imageDisplayDialog = new ImageDisplayDialog();
|
||||
@@ -252,11 +335,11 @@ namespace LibationAvalonia.Views
|
||||
imageDisplayDialog.Show();
|
||||
}
|
||||
|
||||
public void Description_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
public void Description_Click(object sender, Avalonia.Input.TappedEventArgs args)
|
||||
{
|
||||
if (sender is TextBlock tblock && tblock.DataContext is GridEntry gEntry)
|
||||
if (sender is Control tblock && tblock.DataContext is GridEntry gEntry)
|
||||
{
|
||||
var pt = tblock.Parent.PointToScreen(tblock.Parent.Bounds.TopRight);
|
||||
var pt = tblock.PointToScreen(tblock.Bounds.TopRight);
|
||||
var displayWindow = new DescriptionDisplayDialog
|
||||
{
|
||||
SpawnLocation = new Point(pt.X, pt.Y),
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user