mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-11 05:07:20 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f3f069d54 | ||
|
|
88e3bbab89 | ||
|
|
ca52ed4b7a | ||
|
|
bb4a5298ae | ||
|
|
9512e62deb | ||
|
|
91174ed8dd | ||
|
|
b9490966bb | ||
|
|
d1114b410e | ||
|
|
49e9b0c9ce | ||
|
|
0b0d1afc48 | ||
|
|
77f518f8d3 | ||
|
|
9f1d4a383b | ||
|
|
74b18c170a | ||
|
|
c85c11b27a | ||
|
|
c8ac585ab3 | ||
|
|
55b45ae45d | ||
|
|
776cd5215f | ||
|
|
1b8840c3b0 | ||
|
|
97537df933 | ||
|
|
eb4ab59737 | ||
|
|
55c2bf3be7 | ||
|
|
ebce0c073b | ||
|
|
0499bbae7e | ||
|
|
4be0361e42 | ||
|
|
c2c7b04acd | ||
|
|
f194032139 | ||
|
|
a21bb8174d | ||
|
|
3fc5ab0f8d | ||
|
|
591a6d1594 | ||
|
|
c4ac900045 | ||
|
|
50fde66a4c | ||
|
|
597c8f7b3d | ||
|
|
4ffcbeb1db | ||
|
|
6996fb1b81 | ||
|
|
7ef10b1e7d | ||
|
|
0a644695d8 | ||
|
|
86b50b45ef | ||
|
|
727c869ba9 | ||
|
|
f330d8bed6 | ||
|
|
9aecd26aea | ||
|
|
0cc2ef773d | ||
|
|
78371e3522 | ||
|
|
24a896e61b | ||
|
|
e4daa5b8e8 | ||
|
|
505c614210 | ||
|
|
df531de255 | ||
|
|
7b1dd64936 | ||
|
|
c226e8ca38 | ||
|
|
673ff153b0 | ||
|
|
5b898dcd48 | ||
|
|
b1df70f66c | ||
|
|
220c916258 | ||
|
|
c3e0247940 | ||
|
|
0891fcf256 | ||
|
|
b259fdc9a5 | ||
|
|
ecee08aaf7 | ||
|
|
b53a092327 | ||
|
|
0c838a483e | ||
|
|
d934511c32 | ||
|
|
8c88739644 | ||
|
|
05e1de78da | ||
|
|
dd059a0ce9 | ||
|
|
4f401fe6c7 | ||
|
|
849f5f508e | ||
|
|
ac2b5ef3e2 | ||
|
|
13bdebbe9d | ||
|
|
6445a758a2 | ||
|
|
f6b0765dbc | ||
|
|
95dfee130e | ||
|
|
b8616a6da0 | ||
|
|
0b975a168e | ||
|
|
f610ccdf58 | ||
|
|
f0c61ec7db | ||
|
|
14bd9d9deb | ||
|
|
01487690d4 | ||
|
|
acfde519f5 | ||
|
|
7fb6ea4701 | ||
|
|
db1566a32d | ||
|
|
ca48767cc1 | ||
|
|
310abe951a | ||
|
|
aed2aec38e | ||
|
|
531d98a0d5 | ||
|
|
dd8bba959d | ||
|
|
9d6f0f7700 | ||
|
|
274cbf37ca | ||
|
|
2b25ced65b | ||
|
|
417e654799 | ||
|
|
e9550055c6 | ||
|
|
fa64be7ad0 | ||
|
|
e3508b2998 | ||
|
|
30809fb38d | ||
|
|
5b89f29a4f | ||
|
|
ea33d8933a | ||
|
|
c1e9911ade | ||
|
|
962a5055b2 | ||
|
|
3108bfa469 | ||
|
|
ccc74cef56 | ||
|
|
1de778adef | ||
|
|
4a30acf0eb | ||
|
|
6d326ebabc | ||
|
|
f2ef616203 | ||
|
|
87e8c6a32a | ||
|
|
62dfc20255 | ||
|
|
9f255a3bc1 | ||
|
|
6d7af0a8ae | ||
|
|
650b9c7f5b | ||
|
|
dc353aa7e5 | ||
|
|
d5258b7e64 | ||
|
|
475f20b3cb | ||
|
|
6efc64b927 | ||
|
|
d2cc9ff26f | ||
|
|
cfe4770387 | ||
|
|
33967adb15 | ||
|
|
9ef8dca4d5 | ||
|
|
af1029f4b5 | ||
|
|
63e7f0f44e | ||
|
|
65623e8e81 | ||
|
|
a5912efa0c | ||
|
|
dba3e6ca8e | ||
|
|
a4bc39ee6b | ||
|
|
675fecec67 | ||
|
|
be1eac5692 | ||
|
|
8bad7d90c3 | ||
|
|
c72e5a3568 | ||
|
|
e145e46579 | ||
|
|
5fd8ac481b | ||
|
|
b6c7612ec4 | ||
|
|
3456eb6c35 | ||
|
|
c1b8cc894a | ||
|
|
56c15d3108 | ||
|
|
810ea90770 | ||
|
|
b90cc67b09 | ||
|
|
550a99ac90 | ||
|
|
9ed89eca9e | ||
|
|
197d645e10 | ||
|
|
bdfc99f209 | ||
|
|
a010da5251 | ||
|
|
4c212e752f | ||
|
|
e8d2ac5c62 | ||
|
|
6c552623d9 | ||
|
|
5a0d62687c | ||
|
|
f4e61d0445 | ||
|
|
54765fc181 | ||
|
|
d292cb0472 | ||
|
|
b4f48cb54c | ||
|
|
e7d08d30b0 | ||
|
|
26946e8a87 | ||
|
|
ecf1cf4404 | ||
|
|
8213521404 | ||
|
|
0ef73464b8 | ||
|
|
3030b748d7 | ||
|
|
ccef50d681 | ||
|
|
78e6d7c5c1 | ||
|
|
846b93c97c | ||
|
|
b0e79faacf | ||
|
|
3fece6aa44 | ||
|
|
a7c7bd9925 | ||
|
|
3373b0979a | ||
|
|
3e3da2298e | ||
|
|
a9cb560f95 | ||
|
|
e4282f014e | ||
|
|
b694e319f7 | ||
|
|
67e3e41b15 | ||
|
|
ad2101ab8c | ||
|
|
d7ea8eb8f1 | ||
|
|
5c1153e187 | ||
|
|
e929ab3062 | ||
|
|
d64fd564ab | ||
|
|
340b0a0d29 | ||
|
|
1f217b3966 | ||
|
|
accb43456b | ||
|
|
b3a781de5e | ||
|
|
34613f7e97 | ||
|
|
1def16a157 | ||
|
|
33a209df3c | ||
|
|
2daf2da30a | ||
|
|
dd2bc7c461 | ||
|
|
1d1936734b | ||
|
|
e7f6e71d3b | ||
|
|
0da8af3b8f | ||
|
|
f159bcdbb9 | ||
|
|
fc85aa457c | ||
|
|
687a51b7bc | ||
|
|
477d0088e2 | ||
|
|
38138d8272 | ||
|
|
fc92c633e1 | ||
|
|
3c71cb10b2 | ||
|
|
65d8adc018 | ||
|
|
9e739e48d7 | ||
|
|
6b26280aa8 | ||
|
|
14d8bb2205 | ||
|
|
0960edeafb | ||
|
|
6d2b0b952d | ||
|
|
fb277cff81 | ||
|
|
dbd5af8ab0 | ||
|
|
02b9b4fa1c | ||
|
|
7ecd571364 | ||
|
|
32f3e6a886 | ||
|
|
e477c29890 | ||
|
|
a8621699c1 | ||
|
|
cd9a070784 | ||
|
|
d8ce7cc9b0 | ||
|
|
1d6d4ff9ac | ||
|
|
db3a810f47 | ||
|
|
508ea1032d | ||
|
|
10fc5837cb | ||
|
|
812e0c3b60 | ||
|
|
d161bdfaeb | ||
|
|
a6aa14c7f8 | ||
|
|
7dd55a2545 | ||
|
|
1081dfe8df | ||
|
|
3f716cd1ac |
No files matched your search
@@ -0,0 +1,5 @@
|
||||
# https://editorconfig.org
|
||||
root = true
|
||||
|
||||
[*.md]
|
||||
charset = utf-8
|
||||
@@ -40,4 +40,6 @@ Attach your Libation log file here. If your user folder contains the file "Libat
|
||||
|macOS|`~/Library/Application Support/Libation`|
|
||||
|Linux|`~/.local/share/Libation`|
|
||||
|
||||
**macOS:** If that folder does not exist because Libation never starts, try launching from Terminal and say whether it works: `/Applications/Libation.app/Contents/MacOS/Libation` (see [Troubleshooting for macOS](https://github.com/rmcrackan/Libation/blob/main/docs/advanced/troubleshoot.md#macos)).
|
||||
|
||||
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
|
||||
@@ -0,0 +1,10 @@
|
||||
# Labels required by exempt-creators.yml and stale.yml. Sync with sync-labels workflow.
|
||||
# (Other repo labels are left unchanged when prune: false.)
|
||||
---
|
||||
- name: stale
|
||||
color: 'eeeeee'
|
||||
description: 'No activity for an extended period'
|
||||
|
||||
- name: exempt
|
||||
color: '0e8a16'
|
||||
description: 'Excluded from automatic stale closure (e.g. maintainer-opened)'
|
||||
@@ -34,7 +34,7 @@ jobs:
|
||||
RUNTIME_ID: "osx-${{ inputs.architecture }}"
|
||||
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
|
||||
steps:
|
||||
- uses: apple-actions/import-codesign-certs@v6
|
||||
- uses: apple-actions/import-codesign-certs@v7
|
||||
if: ${{ inputs.sign-app }}
|
||||
with:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
@@ -85,12 +85,21 @@ jobs:
|
||||
WAIT="--wait"
|
||||
fi
|
||||
echo "::debug::Submitting the disk image for notarization"
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
|
||||
# Capture stdout+stderr (2>&1). Use || true so that when notarytool fails (e.g. Apple TOS
|
||||
# agreement required), the script does not exit before we can print RESPONSE—otherwise the
|
||||
# job would fail with no visible error message in the workflow log.
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) || true
|
||||
echo "$RESPONSE"
|
||||
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
|
||||
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
echo "::notice::Notary Submission Id: $SUBMISSION_ID"
|
||||
|
||||
# Re-fail the step if submit failed (e.g. no submission id). The job still fails, but the
|
||||
# output above is now visible in the log so we can see the real error (e.g. sign agreement).
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "::error::Notarization submit failed. See output above for details (e.g. Apple TOS agreement)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
echo "::debug::Stapling the notarization ticket to the disk image"
|
||||
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
|
||||
|
||||
@@ -45,13 +45,13 @@ jobs:
|
||||
node-version: 24
|
||||
cache: npm # or pnpm / yarn
|
||||
- name: Setup Pages
|
||||
uses: actions/configure-pages@v5
|
||||
uses: actions/configure-pages@v6
|
||||
- name: Install dependencies
|
||||
run: npm ci # or pnpm install / yarn install / bun install
|
||||
- name: Build with VitePress
|
||||
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@v4
|
||||
uses: actions/upload-pages-artifact@v5
|
||||
with:
|
||||
path: .vitepress/dist
|
||||
|
||||
@@ -66,4 +66,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -55,6 +55,8 @@ jobs:
|
||||
- name: Build and push image
|
||||
uses: docker/build-push-action@v7
|
||||
with:
|
||||
# Avoid default git context refs/pull/*/merge (breaks after PR merge or on some re-runs).
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.metadata.outputs.tags != ''}}
|
||||
cache-from: type=gha
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# One-time or occasional backfill: add `exempt` to open issues/PRs opened by
|
||||
# rmcrackan or Mbucari that predate exempt-creators.yml. Run from Actions →
|
||||
# "Exempt creators backfill" → Run workflow.
|
||||
---
|
||||
name: Exempt creators backfill
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
backfill:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const exemptLabel = 'exempt';
|
||||
const creators = new Set(['rmcrackan', 'mbucari']);
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const items = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let labeled = 0;
|
||||
let notCreator = 0;
|
||||
let alreadyExempt = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const login = item.user && item.user.login;
|
||||
if (!login || !creators.has(login.toLowerCase())) {
|
||||
notCreator++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const names = item.labels.map((l) => l.name);
|
||||
if (names.some((n) => n.toLowerCase() === exemptLabel)) {
|
||||
alreadyExempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [exemptLabel],
|
||||
});
|
||||
labeled++;
|
||||
} catch (e) {
|
||||
core.warning(`#${item.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Backfill done: added exempt to ${labeled} item(s); ` +
|
||||
`${alreadyExempt} already had exempt; ${notCreator} not from rmcrackan/Mbucari.`
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
# Assigns the `exempt` label to issues and PRs opened by maintainer accounts so
|
||||
# stale.yml does not mark them stale. See issue #1532.
|
||||
# For issues/PRs opened before this existed, run workflow "Exempt creators backfill".
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/exempt-creators.yml
|
||||
---
|
||||
name: Exempt specific creators
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
exempt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add exempt label
|
||||
if: >-
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.issue.user.login)) ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.pull_request.user.login))
|
||||
uses: actions/github-script@v9
|
||||
with:
|
||||
script: |
|
||||
const number = context.issue?.number || context.payload.pull_request?.number;
|
||||
const repo = context.repo;
|
||||
if (number) {
|
||||
await github.rest.issues.addLabels({
|
||||
...repo,
|
||||
issue_number: number,
|
||||
labels: ['exempt'],
|
||||
});
|
||||
}
|
||||
@@ -49,7 +49,7 @@ jobs:
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
uses: softprops/action-gh-release@v2
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||
body: "<Put a body here>"
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
# Marks inactive issues/PRs stale and closes them after a further quiet period.
|
||||
# Exempt labels include `enhancement`, `exempt` (from exempt-creators.yml), and others below.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/stale.yml
|
||||
---
|
||||
name: Close stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-close: 14
|
||||
operations-per-run: 100
|
||||
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If this issue is still relevant, please leave a comment to keep it open.
|
||||
Thank you for your contributions to Libation.
|
||||
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
|
||||
If you believe this issue is still relevant, please reopen it or create a new issue with updated information.
|
||||
Thank you for your understanding.
|
||||
|
||||
stale-issue-label: stale
|
||||
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If you're still working on this PR, please leave a comment to keep it open.
|
||||
Thank you for your contribution.
|
||||
|
||||
close-pr-message: |
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
|
||||
If you'd like to continue working on this, please reopen the PR or submit a new one.
|
||||
Thank you for your contribution.
|
||||
|
||||
stale-pr-label: stale
|
||||
|
||||
exempt-issue-labels: 'priority: critical,priority: high,security,help wanted,good first issue,needs-discussion,enhancement,exempt'
|
||||
exempt-pr-labels: 'priority: critical,priority: high,security,enhancement,exempt'
|
||||
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: false
|
||||
exempt-draft-pr: true
|
||||
ascending: true
|
||||
remove-stale-when-updated: true
|
||||
debug-only: false
|
||||
@@ -0,0 +1,29 @@
|
||||
# Ensures labels referenced by exempt-creators / stale workflows exist.
|
||||
# Optional per #1532 — run workflow_dispatch if labels are missing.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/sync-labels.yml
|
||||
---
|
||||
name: Sync labels
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- '.github/labels.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
manifest: .github/labels.yml
|
||||
prune: false
|
||||
@@ -1,5 +1,14 @@
|
||||
/* Custom styles for Libation documentation */
|
||||
|
||||
/* Naming Templates page: monospace in the doc column + outline only.
|
||||
pageClass is applied to .Layout in VitePress; limit scope to #VPContent
|
||||
so the top nav, sidebar, and site footer keep the default font. */
|
||||
.Layout.naming-templates-mono #VPContent,
|
||||
.Layout.naming-templates-mono #VPContent * {
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
|
||||
@media (min-width: 640px) and (max-width: 959px) {
|
||||
/* Target specific nav items by their position */
|
||||
|
||||
+47
-20
@@ -72,43 +72,70 @@ create_db() {
|
||||
}
|
||||
|
||||
setup_db() {
|
||||
DBPATH=$1
|
||||
dbpattern="*.db"
|
||||
local DBPATH=$1
|
||||
local dbpattern="*.db"
|
||||
local FILE LINK LINK_ORIGIN
|
||||
local -a files
|
||||
|
||||
debug "using database directory ${DBPATH}"
|
||||
|
||||
# Figure out the right databse file
|
||||
if [[ -z "${LIBATION_DB_FILE}" ]];
|
||||
if [[ -z $LIBATION_DB_FILE ]]
|
||||
then
|
||||
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
|
||||
if [ "${dbCount}" -gt 1 ];
|
||||
then
|
||||
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||
exit 1
|
||||
elif [ "${dbCount}" -eq 1 ];
|
||||
then
|
||||
files=( ${DBPATH}/${dbpattern} )
|
||||
FILE=${files[0]}
|
||||
else
|
||||
FILE="${DBPATH}/LibationContext.db"
|
||||
fi
|
||||
shopt -s nullglob # files shall be empty if no match is found
|
||||
files=( "${DBPATH}"/${dbpattern} )
|
||||
shopt -u nullglob
|
||||
|
||||
case ${#files[@]} in
|
||||
0)
|
||||
FILE="${DBPATH}/LibationContext.db"
|
||||
;;
|
||||
1)
|
||||
FILE="${files[0]}"
|
||||
;;
|
||||
*)
|
||||
debug "found ${#files[@]} database files matching '${dbpattern}'"
|
||||
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
FILE="${DBPATH}/${LIBATION_DB_FILE}"
|
||||
fi
|
||||
|
||||
debug "planning to use database ${FILE}"
|
||||
debug "planning to use database '${FILE}'"
|
||||
|
||||
if [ -f "${FILE}" ]; then
|
||||
if [[ -f $FILE ]]
|
||||
then
|
||||
info "database found at ${FILE}"
|
||||
elif [ ${LIBATION_CREATE_DB} = "true" ];
|
||||
elif [[ $LIBATION_CREATE_DB == "true" ]]
|
||||
then
|
||||
warn "database not found, creating one at ${FILE}"
|
||||
create_db ${FILE}
|
||||
create_db "${FILE}"
|
||||
else
|
||||
error "database not found and creation is disabled"
|
||||
exit 1
|
||||
fi
|
||||
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||
|
||||
LINK="${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
|
||||
if [[ -L $LINK ]]
|
||||
then
|
||||
# check if the link is correct
|
||||
LINK_ORIGIN=$(readlink -fn "$LINK")
|
||||
if [[ $LINK_ORIGIN == $(readlink -fn "$FILE") ]]
|
||||
then
|
||||
warn "symlink '${LINK_ORIGIN}' to '${LINK}' already established"
|
||||
return 0
|
||||
fi
|
||||
warn "removing existing symlink '${LINK_ORIGIN}' to '${LINK}'"
|
||||
rm -f "$LINK"
|
||||
elif [[ -e $LINK ]]
|
||||
then
|
||||
error "found blocking file at '${LINK}' - can't create symlink"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ln -s "${FILE}" "${LINK}"
|
||||
}
|
||||
|
||||
run() {
|
||||
|
||||
@@ -118,6 +118,7 @@ mkdir Libation/.background
|
||||
mv background.png Libation/.background/
|
||||
ln -s /Applications "./Libation/ "
|
||||
mkdir ./bundle
|
||||
sync
|
||||
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
|
||||
# Create a .DS_Store by:
|
||||
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.3.2.1</Version>
|
||||
<Version>13.4.2.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -225,6 +225,12 @@ public static class LibationScaffolding
|
||||
private static void configureLogging(Configuration config)
|
||||
{
|
||||
config.ConfigureLogging();
|
||||
Log.Information(
|
||||
"Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}",
|
||||
config.LibationFiles.Location,
|
||||
config.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)",
|
||||
Path.Combine(config.LibationFiles.Location, "LibationContext.db"));
|
||||
DbContexts.TryEmitPendingInitialDatabaseStatistics();
|
||||
|
||||
// capture most Console.WriteLine() and write to serilog. See below tests for details.
|
||||
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
|
||||
@@ -282,64 +288,64 @@ public static class LibationScaffolding
|
||||
}
|
||||
|
||||
private static void logStartupState(Configuration config)
|
||||
{
|
||||
{
|
||||
#if DEBUG
|
||||
var mode = "Debug";
|
||||
var mode = "Debug";
|
||||
#else
|
||||
var mode = "Release";
|
||||
#endif
|
||||
if (Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
if (Debugger.IsAttached)
|
||||
mode += " (Debugger attached)";
|
||||
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
// begin logging session with a form feed
|
||||
Log.Logger.Information("\r\n\f");
|
||||
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
catch { return -1; }
|
||||
}
|
||||
static int fileCount(FileManager.LongPath? longPath)
|
||||
{
|
||||
if (longPath is null)
|
||||
return -1;
|
||||
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
|
||||
catch { return -1; }
|
||||
}
|
||||
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly?.GetName().Name,
|
||||
Version = BuildVersion?.ToString(),
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
Environment.OSVersion,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
Log.Logger.Information("Begin. {@DebugInfo}", new
|
||||
{
|
||||
AppName = EntryAssembly?.GetName().Name,
|
||||
Version = BuildVersion?.ToString(),
|
||||
ReleaseIdentifier,
|
||||
Configuration.OS,
|
||||
Environment.OSVersion,
|
||||
InteropFactory.InteropFunctionsType,
|
||||
Mode = mode,
|
||||
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
|
||||
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
|
||||
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
|
||||
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
|
||||
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
|
||||
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
|
||||
|
||||
config.AutoScan,
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
config.AutoScan,
|
||||
config.BetaOptIn,
|
||||
config.UseCoverAsFolderIcon,
|
||||
config.LibationFiles,
|
||||
AudibleFileStorage.BooksDirectory,
|
||||
|
||||
config.InProgress,
|
||||
config.InProgress,
|
||||
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
||||
AudibleFileStorage.DownloadsInProgressDirectory,
|
||||
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
|
||||
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
||||
AudibleFileStorage.DecryptInProgressDirectory,
|
||||
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
|
||||
|
||||
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||
});
|
||||
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
|
||||
});
|
||||
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
|
||||
@@ -29,7 +29,7 @@ public partial record UpgradeProperties
|
||||
ZipUrl = zipUrl;
|
||||
LatestRelease = latestRelease;
|
||||
|
||||
var text = NoAppBlockRegex().Replace(notes, "");
|
||||
var text = NoAppBlockRegex().Replace(notes, "");
|
||||
text = LinkStripRegex().Replace(text, "$1");
|
||||
Notes = text.Trim();
|
||||
}
|
||||
@@ -37,6 +37,6 @@ public partial record UpgradeProperties
|
||||
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
|
||||
private static partial Regex LinkStripRegex();
|
||||
|
||||
[GeneratedRegex(@"<!-- BEGIN NO-APP -->.*?<!-- END NO-APP -->", RegexOptions.Singleline)]
|
||||
private static partial Regex NoAppBlockRegex();
|
||||
[GeneratedRegex(@"<!-- BEGIN NO-APP -->.*?<!-- END NO-APP -->", RegexOptions.Singleline)]
|
||||
private static partial Regex NoAppBlockRegex();
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
@@ -9,13 +12,46 @@ public static class DbContexts
|
||||
{
|
||||
private static bool _sqliteDbValidated;
|
||||
|
||||
private static readonly object _initialDatabaseStatisticsCaptureLock = new();
|
||||
|
||||
/// <summary>
|
||||
/// True after initial DB statistics were read and either written to Serilog or stored for <see cref="TryEmitPendingInitialDatabaseStatistics"/>.
|
||||
/// False if capture has not run yet or the last attempt threw (a later <see cref="GetContext"/> may retry).
|
||||
/// </summary>
|
||||
private static bool _initialDatabaseStatisticsCaptured;
|
||||
|
||||
/// <summary>Shape of the initial DB statistics log event; edit here only when changing what is logged.</summary>
|
||||
private sealed class InitialDatabaseStatistics
|
||||
{
|
||||
public required int LibraryBooksNotInTrash { get; init; }
|
||||
public required int LibraryBooksInTrash { get; init; }
|
||||
public required int BookRecords { get; init; }
|
||||
}
|
||||
|
||||
private static InitialDatabaseStatistics? _pendingInitialDbStats;
|
||||
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
{
|
||||
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
|
||||
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
|
||||
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
|
||||
context.Database.Migrate();
|
||||
try
|
||||
{
|
||||
LibationContextFactory.ApplyMigrations(
|
||||
context,
|
||||
string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString) ? SqliteStorage.DatabasePath : null);
|
||||
}
|
||||
catch (InvalidOperationException ex) when (ex.Message.Contains("Libation cannot write its SQLite database", StringComparison.Ordinal))
|
||||
{
|
||||
Log.Error(
|
||||
ex,
|
||||
"SQLite migrations failed (read-only or blocked). LibationFiles={LibationFiles} DatabasePath={DatabasePath} AppsettingsJson={AppsettingsJson}",
|
||||
Configuration.Instance.LibationFiles.Location,
|
||||
SqliteStorage.DatabasePath,
|
||||
Configuration.Instance.LibationFiles.AppsettingsJsonFile ?? "(null, LIBATION_FILES_DIR may be set)");
|
||||
throw;
|
||||
}
|
||||
|
||||
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
|
||||
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
|
||||
@@ -24,9 +60,59 @@ public static class DbContexts
|
||||
_sqliteDbValidated = true;
|
||||
}
|
||||
|
||||
TryCaptureInitialDatabaseStatistics(context);
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
private static void TryCaptureInitialDatabaseStatistics(LibationContext context)
|
||||
{
|
||||
lock (_initialDatabaseStatisticsCaptureLock)
|
||||
{
|
||||
if (_initialDatabaseStatisticsCaptured)
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
var (notInTrash, inTrash) = context.GetLibraryBookCountsByTrashFlag();
|
||||
var bookRecords = context.GetBookCount();
|
||||
|
||||
var stats = new InitialDatabaseStatistics
|
||||
{
|
||||
LibraryBooksNotInTrash = notInTrash,
|
||||
LibraryBooksInTrash = inTrash,
|
||||
BookRecords = bookRecords,
|
||||
};
|
||||
|
||||
if (Configuration.Instance.SerilogInitialized)
|
||||
LogInitialDatabaseStatistics(stats);
|
||||
else
|
||||
_pendingInitialDbStats = stats;
|
||||
|
||||
_initialDatabaseStatisticsCaptured = true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (Configuration.Instance.SerilogInitialized)
|
||||
Log.Warning(ex, "Could not capture initial database statistics");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes initial DB statistics that were captured before Serilog was configured (e.g. WinForms early library load).
|
||||
/// Call once after <see cref="Configuration.ConfigureLogging"/>.
|
||||
/// </summary>
|
||||
public static void TryEmitPendingInitialDatabaseStatistics()
|
||||
{
|
||||
var pending = Interlocked.Exchange(ref _pendingInitialDbStats, null);
|
||||
if (pending is not null)
|
||||
LogInitialDatabaseStatistics(pending);
|
||||
}
|
||||
|
||||
private static void LogInitialDatabaseStatistics(InitialDatabaseStatistics stats) =>
|
||||
Log.Logger.Information("Initial database statistics. {@DbStats}", stats);
|
||||
|
||||
/// <summary>Use for full library querying. No lazy loading</summary>
|
||||
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
|
||||
{
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using DataLayer;
|
||||
using LibationSearchEngine;
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
@@ -31,6 +32,12 @@ public static class SearchEngineCommands
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
|
||||
{
|
||||
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying query.");
|
||||
fullReIndex(engine);
|
||||
return func(engine);
|
||||
}
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
@@ -76,6 +83,12 @@ public static class SearchEngineCommands
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
catch (Exception ex) when (SearchEngine.IsRecoverableCorruptIndexException(ex))
|
||||
{
|
||||
Log.Warning(ex, "Search index unreadable or corrupt; rebuilding and retrying.");
|
||||
fullReIndex(new SearchEngine());
|
||||
update(action);
|
||||
}
|
||||
}
|
||||
|
||||
private static void update(Action<SearchEngine> action)
|
||||
|
||||
@@ -88,7 +88,8 @@ public class AccountsSettings : IUpdatable
|
||||
var l = Localization.Get(locale);
|
||||
var id = new Identity(l);
|
||||
|
||||
var account = new Account(accountId) { IdentityTokens = id };
|
||||
// Match GUI default for new rows (WinForms/Avalonia): include account in library scans.
|
||||
var account = new Account(accountId) { IdentityTokens = id, LibraryScan = true };
|
||||
Add(account);
|
||||
return account;
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.1.4.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.33.5" />
|
||||
<PackageReference Include="AudibleApi" Version="10.1.5.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -7,6 +7,7 @@ using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
@@ -53,7 +54,9 @@ public partial class Mkb79Auth : IIdentityMaintainer
|
||||
public Dictionary<string, string?>? WebsiteCookies
|
||||
{
|
||||
get => _websiteCookies?.ToObject<Dictionary<string, string?>>();
|
||||
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
private set => _websiteCookies = value is null || value.Count == 0
|
||||
? null
|
||||
: JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
|
||||
}
|
||||
|
||||
[JsonIgnore]
|
||||
@@ -123,7 +126,75 @@ public partial class Mkb79Auth
|
||||
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
|
||||
|
||||
public string ToJson()
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
|
||||
{
|
||||
var jo = JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings));
|
||||
ApplyAudibleCliExportConventions(jo);
|
||||
return jo.ToString(Formatting.Indented);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// audible-cli expects <c>website_cookies</c> as JSON null when empty (not <c>{}</c>) and a PEM
|
||||
/// <c>device_private_key</c> with standard 64-character base64 lines and newline separators.
|
||||
/// </summary>
|
||||
internal static void ApplyAudibleCliExportConventions(JObject jo)
|
||||
{
|
||||
if (jo["website_cookies"] is JObject wc && !wc.Properties().Any())
|
||||
jo["website_cookies"] = JValue.CreateNull();
|
||||
|
||||
if (jo["device_private_key"]?.Type == JTokenType.String)
|
||||
{
|
||||
var s = jo["device_private_key"]!.Value<string>();
|
||||
var formatted = FormatDevicePrivateKeyForAudibleCliExport(s);
|
||||
if (formatted is not null)
|
||||
jo["device_private_key"] = formatted;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FormatDevicePrivateKeyForAudibleCliExport(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value))
|
||||
return value;
|
||||
|
||||
var trimmed = value.Trim();
|
||||
string payload;
|
||||
if (trimmed.StartsWith(PrivateKey.REQUIRED_BEGINNING, StringComparison.Ordinal))
|
||||
{
|
||||
var endIdx = trimmed.LastIndexOf(PrivateKey.REQUIRED_ENDING, StringComparison.Ordinal);
|
||||
if (endIdx < PrivateKey.REQUIRED_BEGINNING.Length)
|
||||
return value;
|
||||
|
||||
payload = trimmed
|
||||
.Substring(PrivateKey.REQUIRED_BEGINNING.Length, endIdx - PrivateKey.REQUIRED_BEGINNING.Length)
|
||||
.Replace("\r", "")
|
||||
.Replace("\n", "")
|
||||
.Replace("\\n", "", StringComparison.Ordinal)
|
||||
.Trim();
|
||||
}
|
||||
else
|
||||
payload = trimmed;
|
||||
|
||||
if (payload.Length == 0)
|
||||
return value;
|
||||
|
||||
try
|
||||
{
|
||||
Convert.FromBase64String(payload);
|
||||
}
|
||||
catch (FormatException)
|
||||
{
|
||||
return value;
|
||||
}
|
||||
|
||||
var sb = new StringBuilder();
|
||||
sb.Append(PrivateKey.REQUIRED_BEGINNING).Append('\n');
|
||||
for (var i = 0; i < payload.Length; i += 64)
|
||||
{
|
||||
var len = Math.Min(64, payload.Length - i);
|
||||
sb.Append(payload, i, len).Append('\n');
|
||||
}
|
||||
sb.Append(PrivateKey.REQUIRED_ENDING).Append('\n');
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
public async Task<Account> ToAccountAsync()
|
||||
{
|
||||
@@ -146,7 +217,8 @@ public partial class Mkb79Auth
|
||||
{
|
||||
DecryptKey = ActivationBytes,
|
||||
AccountName = $"{email} - {Locale.Name}",
|
||||
IdentityTokens = new Identity(Locale)
|
||||
IdentityTokens = new Identity(Locale),
|
||||
LibraryScan = true,
|
||||
};
|
||||
|
||||
account.IdentityTokens.Update(
|
||||
@@ -196,8 +268,7 @@ public partial class Mkb79Auth
|
||||
|
||||
public static class Serialize
|
||||
{
|
||||
public static string ToJson(this Mkb79Auth self)
|
||||
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
|
||||
public static string ToJson(this Mkb79Auth self) => self.ToJson();
|
||||
}
|
||||
|
||||
internal static class Converter
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public enum Mkb79ImportOutcome
|
||||
{
|
||||
Success,
|
||||
DuplicateAccount,
|
||||
InvalidFile,
|
||||
}
|
||||
|
||||
public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null);
|
||||
|
||||
public static class Mkb79AuthImporter
|
||||
{
|
||||
/// <summary>
|
||||
/// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present.
|
||||
/// </summary>
|
||||
public static async Task<Mkb79ImportResult> ImportFromJsonTextAsync(string jsonText)
|
||||
{
|
||||
var mkbAuth = Mkb79Auth.FromJson(jsonText);
|
||||
if (mkbAuth is null)
|
||||
{
|
||||
return new Mkb79ImportResult(
|
||||
Mkb79ImportOutcome.InvalidFile,
|
||||
null,
|
||||
"File did not contain valid mkb79/audible-cli account data.");
|
||||
}
|
||||
|
||||
var account = await mkbAuth.ToAccountAsync();
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (persister.AccountsSettings.Accounts.Any(a =>
|
||||
a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
|
||||
{
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account);
|
||||
}
|
||||
|
||||
persister.AccountsSettings.Add(account);
|
||||
return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account);
|
||||
}
|
||||
}
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Postgres.Migrations
|
||||
{
|
||||
[DbContext(typeof(LibationContext))]
|
||||
[Migration("20260427201825_ReAddCategoryName2")]
|
||||
partial class ReAddCategoryName2
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "10.0.2")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
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("smallint");
|
||||
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
|
||||
|
||||
b.Property<string>("AudibleCategoryId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.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<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsDeleted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.ToTable("LibraryBooks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Property<int>("SeriesId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("integer");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
|
||||
|
||||
b.Property<string>("AudibleSeriesId")
|
||||
.IsRequired()
|
||||
.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("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
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");
|
||||
|
||||
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
|
||||
|
||||
b1.Property<int>("BookId")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Url")
|
||||
.IsRequired()
|
||||
.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<bool>("IsFinished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("timestamp without time zone");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("bigint");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("text");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.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")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
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.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("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Postgres.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class ReAddCategoryName2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "Categories",
|
||||
type: "text",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "Categories");
|
||||
}
|
||||
}
|
||||
@@ -148,6 +148,10 @@ namespace DataLayer.Postgres.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
@@ -9,11 +9,11 @@
|
||||
<OutputType>Library</OutputType>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
+495
@@ -0,0 +1,495 @@
|
||||
// <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("20260427201829_ReAddCategoryName2")]
|
||||
partial class ReAddCategoryName2
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "10.0.2");
|
||||
|
||||
modelBuilder.Entity("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("_categoriesCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("_categoryLaddersCategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.HasIndex("_categoryLaddersCategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryCategoryLadder");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("ContentType")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<DateTime?>("DatePublished")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Description")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<bool>("IsSpatial")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Locale")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId", "CategoryLadderId");
|
||||
|
||||
b.HasIndex("BookId");
|
||||
|
||||
b.HasIndex("CategoryLadderId");
|
||||
|
||||
b.ToTable("BookCategory");
|
||||
});
|
||||
|
||||
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")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Property<int>("CategoryLadderId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryLadderId");
|
||||
|
||||
b.ToTable("CategoryLadders");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Property<int>("ContributorId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleContributorId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.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<bool>("AbsentFromLastScan")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Account")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime>("DateAdded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<DateTime?>("IncludedUntil")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<bool>("IsAudiblePlus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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")
|
||||
.IsRequired()
|
||||
.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("CategoryCategoryLadder", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoriesCategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", null)
|
||||
.WithMany()
|
||||
.HasForeignKey("_categoryLaddersCategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
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")
|
||||
.IsRequired()
|
||||
.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<bool>("IsFinished")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<DateTime?>("LastDownloaded")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<string>("LastDownloadedFileVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<long?>("LastDownloadedFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("LastDownloadedVersion")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b1.Property<int?>("PdfStatus")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b1.Property<string>("Tags")
|
||||
.IsRequired()
|
||||
.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")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
b.Navigation("Rating")
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem")
|
||||
.IsRequired();
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.BookCategory", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Book", "Book")
|
||||
.WithMany("CategoriesLink")
|
||||
.HasForeignKey("BookId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
|
||||
.WithMany("BooksLink")
|
||||
.HasForeignKey("CategoryLadderId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Book");
|
||||
|
||||
b.Navigation("CategoryLadder");
|
||||
});
|
||||
|
||||
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.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("CategoriesLink");
|
||||
|
||||
b.Navigation("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
namespace DataLayer.Migrations;
|
||||
|
||||
/// <inheritdoc />
|
||||
public partial class ReAddCategoryName2 : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Name",
|
||||
table: "Categories",
|
||||
type: "TEXT",
|
||||
nullable: false,
|
||||
defaultValue: "");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Name",
|
||||
table: "Categories");
|
||||
}
|
||||
}
|
||||
@@ -139,6 +139,10 @@ namespace DataLayer.Migrations
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
@@ -13,18 +13,22 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<InternalsVisibleTo Include="DtoImporterService" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<None Update="migrate.json">
|
||||
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
|
||||
|
||||
@@ -256,7 +256,7 @@ public class Book
|
||||
IsAbridged |= isAbridged;
|
||||
IsSpatial = isSpatial ?? IsSpatial;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.FirstCharToUpper() ?? Language;
|
||||
Language = language?.Trim().FirstCharToUpper() ?? Language;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||
|
||||
@@ -20,7 +20,7 @@ public class Category
|
||||
internal int CategoryId { get; private set; }
|
||||
public string AudibleCategoryId { get; }
|
||||
|
||||
public string Name { get; }
|
||||
public string Name { get; internal set; }
|
||||
|
||||
internal List<CategoryLadder> _categoryLadders = new();
|
||||
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace DataLayer;
|
||||
|
||||
@@ -37,4 +40,56 @@ public class LibationContextFactory
|
||||
|
||||
return new LibationContext(options.Options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs EF migrations. For SQLite, wraps SQLITE_READONLY (8) with a clearer message including <paramref name="sqliteDatabaseFilePath"/>.
|
||||
/// Pass <paramref name="sqliteDatabaseFilePath"/> as null when the context is not SQLite.
|
||||
/// </summary>
|
||||
public static void ApplyMigrations(LibationContext context, string? sqliteDatabaseFilePath)
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Database.Migrate();
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
{
|
||||
throw CreateSqliteReadonlyException(sqliteDatabaseFilePath, ex);
|
||||
}
|
||||
}
|
||||
|
||||
/// <inheritdoc cref="ApplyMigrations(LibationContext, string?)"/>
|
||||
public static async Task ApplyMigrationsAsync(LibationContext context, string? sqliteDatabaseFilePath, CancellationToken cancellationToken = default)
|
||||
{
|
||||
try
|
||||
{
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
{
|
||||
throw CreateSqliteReadonlyException(sqliteDatabaseFilePath, ex);
|
||||
}
|
||||
}
|
||||
|
||||
private static InvalidOperationException CreateSqliteReadonlyException(string sqliteDatabaseFilePath, SqliteException ex)
|
||||
{
|
||||
// Match LibationFileManager.Configuration.IsLinux (OperatingSystem.IsLinux); avoid referencing that project from DataLayer.
|
||||
var linuxSection = OperatingSystem.IsLinux()
|
||||
? "\n\nOn Linux: check ownership and permissions on that folder (chmod/chown). Include LibationContext.db-wal and LibationContext.db-shm if they exist. Snap data is often under ~/snap/libation/<revision>/.local/share/Libation - that entire tree must be writable.\n\nSnap: after a refresh, appsettings.json under the new revision folder may still list LibationFiles pointing at an older ~/snap/libation/<old revision>/ path. The crash path may still show that old revision. Edit appsettings.json so LibationFiles matches the folder that contains that file (same revision as current), or set LIBATION_FILES_DIR. See docs/installation/linux.md (Snap) and https://github.com/rmcrackan/Libation/issues/1776.\n\nIf Libation will not start, set environment variable LIBATION_FILES_DIR to an existing writable directory you own, then launch again. After Libation starts, you can also change the Libation Files folder in Settings.\n\nIf this persists on Snap and permissions look correct, try the non-Snap build to rule out confinement blocking writes."
|
||||
: "\n\nAfter Libation starts, you can change the Libation Files folder in Settings. If Libation will not start, set environment variable LIBATION_FILES_DIR to an existing writable directory you own, then launch again.";
|
||||
|
||||
return new InvalidOperationException(
|
||||
$"""
|
||||
Libation cannot write its SQLite database (migrations need write access).
|
||||
|
||||
Database path:
|
||||
{sqliteDatabaseFilePath}
|
||||
|
||||
This path is the library database (metadata and local settings), not your audiobook storage folder.
|
||||
|
||||
This usually means the folder or database file is not writable by your user (wrong owner or permissions), or the location is on a read-only or restricted filesystem.{linuxSection}
|
||||
""",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ public class MockLibraryBook : LibraryBook
|
||||
|
||||
public static MockLibraryBook CreateBook(
|
||||
string account = "someone@email.co",
|
||||
bool absetFromLastScan = false,
|
||||
bool absentFromLastScan = false,
|
||||
DateTime? dateAdded = null,
|
||||
DateTime? datePublished = null,
|
||||
DateTime? includedUntil = null,
|
||||
@@ -120,7 +120,7 @@ public class MockLibraryBook : LibraryBook
|
||||
includedUntil,
|
||||
isAudiblePlus)
|
||||
{
|
||||
AbsentFromLastScan = absetFromLastScan
|
||||
AbsentFromLastScan = absentFromLastScan
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -16,6 +16,10 @@ public static class BookQueries
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetBook(productId);
|
||||
|
||||
/// <summary>Total rows in <see cref="LibationContext.Books"/> (no related entities loaded).</summary>
|
||||
public static int GetBookCount(this LibationContext context)
|
||||
=> context.Books.AsNoTracking().Count();
|
||||
|
||||
public static Book? GetBook(this IQueryable<Book> books, string productId)
|
||||
=> books
|
||||
.GetBooks()
|
||||
|
||||
@@ -62,6 +62,13 @@ public static class LibraryBookQueries
|
||||
.Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent)
|
||||
.getLibrary()
|
||||
.ToList();
|
||||
|
||||
/// <summary>Counts <see cref="LibraryBook"/> rows by <see cref="LibraryBook.IsDeleted"/> (no related entities loaded).</summary>
|
||||
public (int NotInTrash, int InTrash) GetLibraryBookCountsByTrashFlag()
|
||||
{
|
||||
var q = context.LibraryBooks.AsNoTracking();
|
||||
return (q.Count(lb => !lb.IsDeleted), q.Count(lb => lb.IsDeleted));
|
||||
}
|
||||
}
|
||||
|
||||
extension(IQueryable<LibraryBook> library)
|
||||
|
||||
@@ -64,6 +64,7 @@ public class CategoryImporter : ItemsImporterBase
|
||||
category = addCategory(id, name);
|
||||
}
|
||||
|
||||
category.Name = name; // update name if changed
|
||||
categories.Add(category);
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,8 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
//Verbose logging inside getDestinationDirectory
|
||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
//post-download tasks done in parallel.
|
||||
// Post-download tasks (parallel). Folder icon runs afterward so it does not race cover/metadata work
|
||||
// or a still-warming Amazon image cache for the 300x300 asset.
|
||||
Serilog.Log.Verbose("Starting post-liberation finalization tasks");
|
||||
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
|
||||
Task[] finalTasks =
|
||||
@@ -88,7 +89,6 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
|
||||
];
|
||||
|
||||
try
|
||||
@@ -99,25 +99,27 @@ public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptB
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Verbose(ex, "An error occurred in the post-liberation finalization tasks");
|
||||
//Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions.
|
||||
//Swallow DownloadCoverArt, DownloadRecordsAsync, and DownloadMetadataAsync exceptions.
|
||||
//Only fail if the downloaded audio files failed to move to Books directory
|
||||
if (moveFilesTask.IsFaulted)
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
|
||||
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
|
||||
// Same picture key as DownloadCoverArt so we hit the same Amazon asset (and Images cache) the parallel task likely just populated.
|
||||
var folderIconPictureId = libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId;
|
||||
await Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(folderIconPictureId, finalStorageDir, cancellationToken), cancellationToken);
|
||||
|
||||
Serilog.Log.Verbose("Updating liberated status for {@Book}", libraryBook.LogFriendly());
|
||||
await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||
Serilog.Log.Verbose("Setting directory time for {@Book}", libraryBook.LogFriendly());
|
||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||
Serilog.Log.Verbose("Deleting cache files for {@Book}", libraryBook.LogFriendly());
|
||||
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
|
||||
{
|
||||
Serilog.Log.Verbose("Updating liberated status for {@Book}", libraryBook.LogFriendly());
|
||||
await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
|
||||
Serilog.Log.Verbose("Setting directory time for {@Book}", libraryBook.LogFriendly());
|
||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||
Serilog.Log.Verbose("Deleting cache files for {@Book}", libraryBook.LogFriendly());
|
||||
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
|
||||
{
|
||||
//Delete cache files only after the download/decrypt operation completes successfully.
|
||||
FileUtility.SaferDelete(cacheFile.FilePath);
|
||||
}
|
||||
//Delete cache files only after the download/decrypt operation completes successfully.
|
||||
FileUtility.SaferDelete(cacheFile.FilePath);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -120,15 +120,35 @@ public abstract class Processable
|
||||
{
|
||||
if (!fileInfo.Exists) return;
|
||||
|
||||
fileInfo.CreationTimeUtc = getTimeValue(Configuration.CreationTime) ?? fileInfo.CreationTimeUtc;
|
||||
fileInfo.LastWriteTimeUtc = getTimeValue(Configuration.LastWriteTime) ?? fileInfo.LastWriteTimeUtc;
|
||||
|
||||
DateTime? getTimeValue(Configuration.DateTimeSource source) => source switch
|
||||
{
|
||||
Configuration.DateTimeSource.Added => libraryBook.DateAdded,
|
||||
Configuration.DateTimeSource.Published => libraryBook.Book.DatePublished,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
if (getTimeValue(Configuration.CreationTime) is { } creationUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
fileInfo.CreationTimeUtc = creationUtc;
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Could not set creation time for {Path}; filesystem may not support it.", fileInfo.FullName);
|
||||
}
|
||||
}
|
||||
|
||||
if (getTimeValue(Configuration.LastWriteTime) is { } lastWriteUtc)
|
||||
{
|
||||
try
|
||||
{
|
||||
fileInfo.LastWriteTimeUtc = lastWriteUtc;
|
||||
}
|
||||
catch (Exception ex) when (ex is UnauthorizedAccessException or IOException)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Could not set last write time for {Path}; filesystem may not support it.", fileInfo.FullName);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,28 +59,31 @@ public static class UtilityExtensions
|
||||
Title = libraryBook.Book.Title,
|
||||
Subtitle = libraryBook.Book.Subtitle,
|
||||
TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle,
|
||||
Locale = libraryBook.Book.Locale,
|
||||
Locale = new LocaleDto(libraryBook.Book.Locale),
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
DatePublished = libraryBook.Book.DatePublished,
|
||||
|
||||
Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||
Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
|
||||
|
||||
Series = getSeries(libraryBook.Book.SeriesLink),
|
||||
IsAbridged = libraryBook.Book.IsAbridged,
|
||||
Series = GetSeries(libraryBook.Book.SeriesLink),
|
||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||
|
||||
Language = libraryBook.Book.Language,
|
||||
LengthInMinutes = TimeSpan.FromMinutes(libraryBook.Book.LengthInMinutes),
|
||||
Language = libraryBook.Book.Language is null ? null : new CultureInfoDto(libraryBook.Book.Language),
|
||||
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString,
|
||||
BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate,
|
||||
SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate,
|
||||
Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount,
|
||||
LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion.ToVersionString(),
|
||||
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion
|
||||
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion,
|
||||
Tags = libraryBook.Book.UserDefinedItem.TagsEnumerated.Select(s => new StringDto(s)).ToList(),
|
||||
};
|
||||
}
|
||||
|
||||
private static List<SeriesDto>? getSeries(IEnumerable<SeriesBook> seriesBooks)
|
||||
private static List<SeriesDto>? GetSeries(IEnumerable<SeriesBook> seriesBooks)
|
||||
{
|
||||
if (!seriesBooks.Any())
|
||||
return null;
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
|
||||
<PackageReference Include="Polly" Version="8.6.5" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Normalizes stored paths for OS folder picker APIs that are stricter than <see cref="Directory"/> (for example WinForms <c>FolderBrowserDialog</c> / shell <c>SHCreateItemFromParsingName</c>).
|
||||
/// </summary>
|
||||
public static class FolderPickerInitialPath
|
||||
{
|
||||
private const string WinLongPathPrefix = @"\\?\";
|
||||
private const string WinLongUncPrefix = @"\\?\UNC\";
|
||||
|
||||
/// <summary>
|
||||
/// Returns a directory path suitable as a folder picker's starting location, or <c>null</c> to let the OS use its default.
|
||||
/// Verifies the directory exists using the same path rules as <see cref="LongPath"/> before returning a shell-oriented string.
|
||||
/// </summary>
|
||||
public static string? GetExistingDirectoryOrNull(string? path)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return null;
|
||||
|
||||
path = path.Trim();
|
||||
try
|
||||
{
|
||||
LongPath longPath = path;
|
||||
if (!Directory.Exists(longPath))
|
||||
return null;
|
||||
|
||||
var forPicker = ToOsFolderPickerPath(longPath.Path);
|
||||
if (string.IsNullOrWhiteSpace(forPicker))
|
||||
return null;
|
||||
|
||||
if (!Directory.Exists(forPicker))
|
||||
return null;
|
||||
|
||||
try
|
||||
{
|
||||
var full = Path.GetFullPath(forPicker);
|
||||
if (Directory.Exists(full))
|
||||
return full;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
// Path.GetFullPath rejects some edge cases; fall through
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
}
|
||||
|
||||
return forPicker;
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
catch (PathTooLongException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? ToOsFolderPickerPath(string absolutePathFromLongPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(absolutePathFromLongPath))
|
||||
return null;
|
||||
|
||||
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
|
||||
return absolutePathFromLongPath;
|
||||
|
||||
if (absolutePathFromLongPath.StartsWith(WinLongUncPrefix, StringComparison.OrdinalIgnoreCase))
|
||||
return @"\\" + absolutePathFromLongPath.Substring(WinLongUncPrefix.Length);
|
||||
|
||||
if (absolutePathFromLongPath.StartsWith(WinLongPathPrefix, StringComparison.Ordinal))
|
||||
return absolutePathFromLongPath.Substring(WinLongPathPrefix.Length);
|
||||
|
||||
return absolutePathFromLongPath;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,336 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Threading;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public static partial class CommonFormatters
|
||||
{
|
||||
public const string DefaultDateFormat = "yyyy-MM-dd";
|
||||
public const string DefaultTimeSpanFormat = "MMM";
|
||||
|
||||
public delegate TFormatted? PropertyFormatter<in TProperty, out TFormatted>(ITemplateTag templateTag, TProperty? value, string? formatString, CultureInfo? culture);
|
||||
|
||||
public delegate string? PropertyFinalizer<in T>(ITemplateTag templateTag, T? value, CultureInfo? culture);
|
||||
|
||||
public static PropertyFinalizer<TProperty> ToPropertyFormatter<TProperty, TPreFormatted>(PropertyFormatter<TProperty, TPreFormatted> preFormatter,
|
||||
PropertyFinalizer<TPreFormatted> finalizer)
|
||||
{
|
||||
return (templateTag, value, culture) => finalizer(templateTag, preFormatter(templateTag, value, null, culture), culture);
|
||||
}
|
||||
|
||||
public static PropertyFinalizer<TPropertyValue> ToFinalizer<TPropertyValue>(PropertyFormatter<TPropertyValue, string> formatter)
|
||||
{
|
||||
return (templateTag, value, culture) => formatter(templateTag, value, null, culture);
|
||||
}
|
||||
|
||||
public static string? StringFinalizer(ITemplateTag templateTag, string? value, CultureInfo? culture) => value;
|
||||
|
||||
public static TPropertyValue? IdlePreFormatter<TPropertyValue>(ITemplateTag templateTag, TPropertyValue? value, string? formatString, CultureInfo? culture) => value;
|
||||
|
||||
public static string StringFormatter(ITemplateTag _, string? value, string? formatString, CultureInfo? culture)
|
||||
=> _StringFormatter(value, formatString, culture);
|
||||
|
||||
public static string _StringFormatter(string? value, string? formatString, IFormatProvider? provider)
|
||||
=> _StringFormatter(value, formatString, GetCultureInfo(provider));
|
||||
|
||||
private static string _StringFormatter(string? value, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
if (string.IsNullOrEmpty(value)) return string.Empty;
|
||||
if (string.IsNullOrWhiteSpace(formatString) || !StringFormatRegex().TryMatch(formatString, out var match)) return value;
|
||||
|
||||
// first shorten the string if a number is specified in the format string
|
||||
if (match.TryParseInt("left", out var length) && length < value.Length)
|
||||
value = value[..length];
|
||||
|
||||
culture ??= CultureInfo.CurrentCulture;
|
||||
|
||||
return match.Groups["case"].ValueSpan switch
|
||||
{
|
||||
"u" or "U" => value.ToUpper(culture),
|
||||
"l" or "L" => value.ToLower(culture),
|
||||
"T" => culture.TextInfo.ToTitleCase(value),
|
||||
"t" => culture.TextInfo.ToTitleCase(value.ToLower(culture)),
|
||||
_ => value,
|
||||
};
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"^\s*(?<left>\d+)?\s*(?<case>[uUlLtT])?\s*$")]
|
||||
private static partial Regex StringFormatRegex();
|
||||
|
||||
public static string TemplateStringFormatter<T>(T toFormat, string? templateString, IFormatProvider? provider, Dictionary<string, Func<T, object?>> replacements)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(templateString)) return "";
|
||||
|
||||
// if this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider
|
||||
var culture = GetCultureInfo(provider);
|
||||
var oldUiCulture = Thread.CurrentThread.CurrentUICulture;
|
||||
var result = CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().ReplaceWithGaps(templateString, GetValueForMatchingTag, Unescape), string.Empty);
|
||||
Thread.CurrentThread.CurrentUICulture = oldUiCulture;
|
||||
return result;
|
||||
|
||||
string GetValueForMatchingTag(Match m)
|
||||
{
|
||||
var tag = m.Groups["tag"].Value;
|
||||
if (!replacements.TryGetValue(tag, out var getter)) return m.Value;
|
||||
|
||||
var lang = m.ResolveValue("lang");
|
||||
var cultureToUse = lang is null ? culture : CultureInfo.GetCultureInfo(lang);
|
||||
Thread.CurrentThread.CurrentUICulture = cultureToUse ?? oldUiCulture;
|
||||
|
||||
var value = getter(toFormat);
|
||||
var format = m.ResolveValue("format");
|
||||
|
||||
var formatted = value switch
|
||||
{
|
||||
IFormattable formattable => formattable.ToString(format, cultureToUse),
|
||||
_ => _StringFormatter(value?.ToString(), format, cultureToUse),
|
||||
};
|
||||
return formatted.IsNullOrEmpty() ? string.Empty : m.UnescapeValue("pre") + formatted + m.UnescapeValue("post");
|
||||
}
|
||||
}
|
||||
|
||||
private static CultureInfo? GetCultureInfo(IFormatProvider? provider)
|
||||
{
|
||||
return provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo;
|
||||
}
|
||||
|
||||
// Matches runs of spaces followed by a space as well as runs of spaces at the beginning or the end of a string (does NOT touch tabs/newlines).
|
||||
[GeneratedRegex(@"^ +| +(?=$| )")]
|
||||
private static partial Regex CollapseSpacesAndTrimRegex();
|
||||
|
||||
// The templateString is scanned for contained braces with an enclosed tagname.
|
||||
// The tagname may be followed by an optional format specifier separated by a colon.
|
||||
// All other parts of the template string are left untouched as well as the braces where the tagname is unknown.
|
||||
// TemplateStringFormatter will use a dictionary to lookup the tagname and the corresponding value getter.
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
(?<=\G(?: # We lookbehind up to the start or the end of the last match for a tag format.
|
||||
\\. # - '\' escapes always the next character. Especially further '\' and the opening '{'
|
||||
| '[^']*' # - allow 'string' to be included in the format, with '' being an escaped ' character
|
||||
| "[^"]*" # - allow "string" to be included in the format, with "" being an escaped " character
|
||||
| [^\\'"] # - match any other character. This will not catch the tag format at first. Because ...
|
||||
) *? ) # With *? the pattern above tries not to consume the tag format.
|
||||
(?: \{ (?<pre> (?: # With an optional extra '{' surroundings of the tag format might be specified in a group called '<pre>'.
|
||||
\\. # - '\' escapes always the next character. Especially further '\' and the opening '{'
|
||||
| '[^']*' # - allow 'string' to be included in the format
|
||||
| "[^"]*" # - allow "string" to be included in the format
|
||||
| [^\\'"{}] # - match any other character. We also don't want further unescaped '{' or '}' in the format.
|
||||
) * ) )? # Capture all up to the next unescaped '{' to get the optional '<pre>' group.
|
||||
\{ (?<tag> [A-Z0-9]+ | \#) # Capture the tags name as '<tag>'. It is always enclosed in '{' and '}'. It may only contain letters and numbers or be a single '#'.
|
||||
(?:@(?<lang>[a-z-]+))? # Introduced by '@' the tag name may optionally be followed by a language specifier captured in a group called '<lang>'.
|
||||
(?::(?<format>(?:
|
||||
\\. # - '\' escapes always the next character. Especially further '\' and the closing '}'
|
||||
| '[^']*' # - allow 'string' to be included in the format
|
||||
| "[^"]*" # - allow "string" to be included in the format
|
||||
| .
|
||||
) *? ))?
|
||||
\}
|
||||
(?(pre)
|
||||
(?<post>(?:
|
||||
\\.
|
||||
| '[^']*'
|
||||
| "[^"]*"
|
||||
| [^\\'"{}]
|
||||
) * ) \}
|
||||
|
|
||||
)
|
||||
""",
|
||||
RegexOptions.IgnoreCase)]
|
||||
public static partial Regex TagFormatRegex();
|
||||
|
||||
public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture)
|
||||
=> value?.ToString(formatString, culture) ?? "";
|
||||
|
||||
public static string IntegerFormatter(ITemplateTag _, int value, string? formatString, CultureInfo? culture)
|
||||
=> _FloatFormatter(value, formatString, culture);
|
||||
|
||||
public static string FloatFormatter(ITemplateTag _, float value, string? formatString, CultureInfo? culture)
|
||||
=> _FloatFormatter(value, formatString, culture);
|
||||
|
||||
public static string _FloatFormatter(float value, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
culture ??= CultureInfo.CurrentCulture;
|
||||
if (!int.TryParse(formatString, out var numDigits) || numDigits <= 0) return value.ToString(formatString, culture);
|
||||
|
||||
//Zero-pad the integer part
|
||||
formatString = new string('0', numDigits) + ".################";
|
||||
return value.ToString(formatString, culture);
|
||||
}
|
||||
|
||||
public static string MinutesFormatter(ITemplateTag templateTag, TimeSpan value, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
culture ??= CultureInfo.CurrentCulture;
|
||||
formatString ??= DefaultTimeSpanFormat;
|
||||
|
||||
// the format string is build as a custom format for TimeSpans. Time portion like 'h' and 'm' are used to format the minutes and hours part of the TimeSpan.
|
||||
// They are limited by the next greater domain. So 'h' will be between 0 and 23, 'm' between 0 and 59. 'd' will be the total number of days.
|
||||
// To get the total timespan display in terms of total hours or total minutes, we allow the format string to include number formats with uppercase D, H or M.
|
||||
// As there might be up to three numbers shown in the format string, we distinguish between total days, hours and minutes with uppercase D, H and M instead of zeros.
|
||||
// A format "#,##0'minutes'" would for example become "#,##M'minutes'". If you combine them the lower units will be reduced by the higher units.
|
||||
// "D'days and'#,##0'minutes'" will show 1,439 minutes at maximum.
|
||||
// In the first step we search for number formats with uppercase D, H or M, format their values as number and replace them as quoted strings in the format string.
|
||||
var timeSpanForTotal = value;
|
||||
formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalD(), TimeSpan.TicksPerDay);
|
||||
formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalH(), TimeSpan.TicksPerHour);
|
||||
formatString = FormatAsNumberIntoTemplate(templateTag, formatString, culture, ref timeSpanForTotal, RegexMinutesTotalM(), TimeSpan.TicksPerMinute);
|
||||
|
||||
// The formatString should now be a valid TimeSpan format string.
|
||||
return value.ToString(formatString, culture);
|
||||
}
|
||||
|
||||
private static string FormatAsNumberIntoTemplate(ITemplateTag templateTag, string formatString, CultureInfo culture, ref TimeSpan timeSpanForTotal, Regex regex, long ticks)
|
||||
{
|
||||
var total = timeSpanForTotal.Ticks / ticks;
|
||||
var matched = false;
|
||||
var result = regex.Replace(formatString, m =>
|
||||
{
|
||||
matched = true;
|
||||
var numPattern = RegexTimeStampToNumberPattern().Replace(m.Groups["format"].Value, "0");
|
||||
var formatted = FloatFormatter(templateTag, total, numPattern, culture);
|
||||
return $"'{formatted}'";
|
||||
});
|
||||
if (matched) timeSpanForTotal = TimeSpan.FromTicks(timeSpanForTotal.Ticks % ticks);
|
||||
return result;
|
||||
}
|
||||
|
||||
public static string DateTimeFormatter(ITemplateTag _, DateTime value, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
culture ??= CultureInfo.InvariantCulture;
|
||||
if (string.IsNullOrWhiteSpace(formatString))
|
||||
formatString = DefaultDateFormat;
|
||||
return value.ToString(formatString, culture);
|
||||
}
|
||||
|
||||
public static string LanguageShortFormatter(ITemplateTag templateTag, string? language, string? formatString, CultureInfo? culture)
|
||||
{
|
||||
return StringFormatter(templateTag, language, "3u", culture);
|
||||
}
|
||||
|
||||
public static bool TryGetLiteral(string? value, [NotNullWhen(true)] out object? literal)
|
||||
{
|
||||
// check if value is quoted
|
||||
if (StringValueRegex().TryMatch(value, out var stringValue))
|
||||
{
|
||||
// inside the quotes, doubled quotes are used to represent literal quotes. So replace them back to single quotes if there are any.
|
||||
// this match helps to determine which quote type is being used so that the correct one can be replaced.
|
||||
var doubleQuote = stringValue.Groups["double"];
|
||||
literal = doubleQuote.Success
|
||||
? stringValue.Groups["value"].Value.Replace(doubleQuote.Value, stringValue.Groups["quote"].Value)
|
||||
: stringValue.Groups["value"].Value;
|
||||
return true;
|
||||
}
|
||||
|
||||
if (int.TryParse(value, out var intValue))
|
||||
{
|
||||
literal = intValue;
|
||||
return true;
|
||||
}
|
||||
|
||||
literal = null;
|
||||
return false;
|
||||
}
|
||||
|
||||
[GeneratedRegex("""^\s*(?<quote>['"])(?<value>(?:(?<double>\k<quote>{2})|.)*)\k<quote>\s*$""")]
|
||||
private static partial Regex StringValueRegex();
|
||||
|
||||
public static string Unescape(string valueSpan)
|
||||
{
|
||||
return Unescape(valueSpan, ['\'', '"']);
|
||||
}
|
||||
|
||||
public static string Unescape(ReadOnlySpan<char> valueSpan, ReadOnlySpan<char> quoteChars, bool unquoteBackslash = true, bool unescapeDoubleQuotesInsideQuotes = true)
|
||||
{
|
||||
if (valueSpan.IsEmpty) return "";
|
||||
|
||||
Span<char> search = stackalloc char[quoteChars.Length + 1];
|
||||
search[0] = '\\';
|
||||
quoteChars.CopyTo(search[1..]);
|
||||
|
||||
var first = valueSpan.IndexOfAny(search);
|
||||
if (first < 0)
|
||||
return valueSpan.ToString();
|
||||
|
||||
var sb = new StringBuilder(valueSpan.Length);
|
||||
sb.Append(valueSpan[..first]);
|
||||
for (var i = first; i < valueSpan.Length; i++)
|
||||
{
|
||||
var c = valueSpan[i];
|
||||
|
||||
// scan quotation
|
||||
if (quoteChars.Contains(c))
|
||||
{
|
||||
i++; // skip quote
|
||||
|
||||
while (i < valueSpan.Length)
|
||||
{
|
||||
var inner = valueSpan[i];
|
||||
|
||||
// closing quote?
|
||||
if (inner == c)
|
||||
{
|
||||
i++; // skip
|
||||
if (!unescapeDoubleQuotesInsideQuotes ||
|
||||
i >= valueSpan.Length ||
|
||||
valueSpan[i] != c)
|
||||
// end block if no 2nd quote follows or doubled quotes don't have special meaning
|
||||
break;
|
||||
}
|
||||
|
||||
sb.Append(inner);
|
||||
i++;
|
||||
}
|
||||
|
||||
i--; // skipped one too much as outer loop advances as well
|
||||
continue;
|
||||
}
|
||||
|
||||
if (c == '\\' && unquoteBackslash && i + 1 < valueSpan.Length)
|
||||
i++; // skip backslash and take the next char
|
||||
|
||||
sb.Append(valueSpan[i]);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
// These search for number formats with all notions of escaping and quoting, but all zeros replaced with D, H, or M to indicate that they should be replaced with the total number of
|
||||
// days hours or minutes in the timespan (not just the minutes part). Only one of them is written commented. The others are identical except for the letter D, H or M.
|
||||
// I most cases this regex will only find a straight bunch of D's, H's or M's, but it also allows for more complex formats.
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
(?<=\G(?: # We lookbehind up to the start or the end of the last match for a number format.
|
||||
\\. # - '\' escapes always the next character. Especially further '\'
|
||||
| '[^']*' # - allow 'string' to be included in the format
|
||||
| "[^"]*" # - allow "string" to be included in the format
|
||||
| [^\\'"] # - match any other character. This will not catch the number format at first. Because ...
|
||||
) *? ) # With *? the pattern above tries not to consume the number format.
|
||||
(?<format> # We capture the whole number format in a group called '<format>'.
|
||||
(?:\#[\#,.]*)? # - For grouping a number format may start with `#` and grouping hints `,` or even a decimal point `.`.
|
||||
D # - At least one unescaped, unquoted uppercase D must be included in the format to indicate that this is a total days format.
|
||||
(?:(?: # - Before further D's, there may be any combination of escaped characters and quoted strings.
|
||||
\\. # - '\' escapes always the next character. Especially further '\' and the closing ']'
|
||||
| '[^']*' # - allow 'string' to be included in the format
|
||||
| "[^"]*" # - allow "string" to be included in the format
|
||||
)* [D%‰\#,.]+ # After escaped characters and quoted strings, there needs to be at least one more real number format character (which may be D as well).
|
||||
)* # This may extend the format several times, for example in `D\:DD` or `D' days 'D\-D`.
|
||||
(?:[Ee][+-]?0+)? # The original number format may end with an optional scientific notation part. This is also optional.
|
||||
) # end of capture group '<format>'
|
||||
""")]
|
||||
private static partial Regex RegexMinutesTotalD();
|
||||
|
||||
[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)(?<format>(?:#[#,.]*)?H(?:(?:\\.|'[^']*'|"[^"]*")*[H%‰#,.]+)*(?:[Ee][+-]?0+)?)""")]
|
||||
private static partial Regex RegexMinutesTotalH();
|
||||
|
||||
[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|[^\\'"])*?)(?<format>(?:#[#,.]*)?M(?:(?:\\.|'[^']*'|"[^"]*")*[M%‰#,.]+)*(?:[Ee][+-]?0+)?)""")]
|
||||
private static partial Regex RegexMinutesTotalM();
|
||||
|
||||
// Capture all D H or M characters in the number format, so that they can be replaced with zeros.
|
||||
[GeneratedRegex("""(?<=\G(?:\\.|'[^']*'|"[^"]*"|.)*?)[DHM]""")]
|
||||
private static partial Regex RegexTimeStampToNumberPattern();
|
||||
}
|
||||
@@ -0,0 +1,291 @@
|
||||
using System;
|
||||
using System.Collections.Concurrent;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public static partial class CompareCondition
|
||||
{
|
||||
private static readonly ConcurrentDictionary<string, Regex> RegexCache = new();
|
||||
|
||||
private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
public static (object, ConditionEvaluator) GetPredicateAndValue(string exactName, string? checkString)
|
||||
{
|
||||
if (checkString is null)
|
||||
return (string.Empty, (v1, _, _) => v1 switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(),
|
||||
_ => !string.IsNullOrWhiteSpace(v1.ToString())
|
||||
});
|
||||
|
||||
var match = GetMatch(exactName, checkString);
|
||||
var valStr = match.UnescapeValue("val");
|
||||
var (evaluator, opGroup) = GetPredicate(exactName, match);
|
||||
|
||||
return (opGroup.Name switch
|
||||
{
|
||||
"num_op" => int.Parse(valStr), // at this stage <val> should have matched digits in CheckRegex
|
||||
"list_op" => new[] { valStr },
|
||||
_ => valStr
|
||||
}, evaluator);
|
||||
}
|
||||
|
||||
public static ConditionEvaluator GetPredicate(string exactName, string? checkString)
|
||||
{
|
||||
return GetPredicate(exactName, GetMatch(exactName, checkString)).Item1;
|
||||
}
|
||||
|
||||
private static Match GetMatch(string exactName, string? checkString)
|
||||
{
|
||||
return CheckRegex().TryMatch(checkString, out var match)
|
||||
? match
|
||||
: throw new ArgumentException($"Invalid check or operator format in conditional tag '{exactName}'. Check string: '{checkString}'");
|
||||
}
|
||||
|
||||
private static (ConditionEvaluator, Group) GetPredicate(string exactName, Match match)
|
||||
{
|
||||
var group = match.Groups["num_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForNumOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["list_op"];
|
||||
if (group.Success)
|
||||
{
|
||||
return (GetPredicateForListOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
group = match.Groups["op"];
|
||||
return (GetPredicateForStringOp(exactName, group.ValueSpan), group);
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForNumOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
Func<int, int, CultureInfo?, bool> checkInt = opString switch
|
||||
{
|
||||
"#=" => (v1, v2, _) => v1 == v2,
|
||||
"#!=" or "≠" or "≠" => (v1, v2, _) => v1 != v2,
|
||||
"#>=" or ">=" or "≥" => (v1, v2, _) => v1 >= v2,
|
||||
"#>" or ">" => (v1, v2, _) => v1 > v2,
|
||||
"#<=" or "<=" or "≤" => (v1, v2, _) => v1 <= v2,
|
||||
"#<" or "<" => (v1, v2, _) => v1 < v2,
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => ToIntObject(v1) is { } i1 && ToIntObject(v2) is { } i2 && checkInt(i1, i2, culture);
|
||||
}
|
||||
|
||||
private static int? ToIntObject(object? value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
null => null,
|
||||
IEnumerable<object> e => e.Count(),
|
||||
TimeSpan ts => (int)ts.TotalMinutes,
|
||||
DateTime dt => (int)dt.ToOADate(),
|
||||
string s => s.Length,
|
||||
int i => i,
|
||||
_ => null // language and such shall never match
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForListOp(string _, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checklist = opString switch
|
||||
{
|
||||
"∋" or ">>" or ":contains:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊇" or ">=>" or ":superset:" => Swap<IEnumerable<string>>(IsSubset),
|
||||
"⊃" or ">->" or ":proper_superset:" => Swap<IEnumerable<string>>(IsProperSubset),
|
||||
"∌" or "!>>" or "∌" or ":not_contains:" => Invert(Swap<IEnumerable<string>>(IsSubset)),
|
||||
"∈" or "<<" or ":in:" => IsSubset,
|
||||
"⊆" or "<=<" or ":subset:" => IsSubset,
|
||||
"⊂" or "<-<" or ":proper_subset:" => IsProperSubset,
|
||||
"∉" or "!<<" or "∉" or ":not_in:" => Invert<IEnumerable<string>>(IsSubset),
|
||||
"⋂" or "&&" or ":overlaps:" => Overlaps,
|
||||
"⋂̸" or "&&!" or "⋂!" or ":disjoint:" => Invert<IEnumerable<string>>(Overlaps),
|
||||
"≡" or "==" or ":equals:" => (e1, e2, culture) =>
|
||||
{
|
||||
var cmp = GetStringComparer(culture);
|
||||
return e1.OrderBy(e => e, cmp).SequenceEqual(e2.OrderBy(e => e, cmp), cmp);
|
||||
},
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => v1 is not null && v2 is not null && checklist(ToEnumerable(v1), ToEnumerable(v2), culture);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> ToEnumerable(object value)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
IEnumerable<string> e => e,
|
||||
IEnumerable<object> e => e.Select(o => o.ToString() ?? ""),
|
||||
string s => [s],
|
||||
_ => [value.ToString() ?? ""]
|
||||
};
|
||||
}
|
||||
|
||||
private static ConditionEvaluator GetPredicateForStringOp(string exactName, ReadOnlySpan<char> opString)
|
||||
{
|
||||
var checkItem = opString switch
|
||||
{
|
||||
"=" or "" => GetStringEqCheck(),
|
||||
"!=" or "!" => Invert(GetStringEqCheck()),
|
||||
"=~" or "~" => GetRegExpCheck(exactName),
|
||||
"!~" => Invert(GetRegExpCheck(exactName)),
|
||||
_ => throw new ArgumentOutOfRangeException() // this should never happen because the regex only allows these values
|
||||
};
|
||||
return (v1, v2, culture) => (v1, v2) switch
|
||||
{
|
||||
(null, _) => false,
|
||||
(_, null) => false,
|
||||
(IEnumerable<object> e1, _) => e1.Any(l => checkItem(l, v2, culture)),
|
||||
(_, IEnumerable<object> e2) => e2.Any(r => checkItem(v1, r, culture)),
|
||||
_ => checkItem(v1, v2, culture)
|
||||
};
|
||||
}
|
||||
|
||||
private static bool Overlaps(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.Any(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
return e1.All(l => e2.Contains(l, comparer));
|
||||
}
|
||||
|
||||
private static bool IsProperSubset(IEnumerable<string> e1, IEnumerable<string> e2, CultureInfo? culture)
|
||||
{
|
||||
var comparer = GetStringComparer(culture);
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
return e1.All(l => e2.Contains(l, comparer)) && e2.Any(r => !e1.Contains(r, comparer));
|
||||
// ReSharper restore PossibleMultipleEnumeration
|
||||
}
|
||||
|
||||
private static Func<T, T, CultureInfo?, bool> Invert<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => !condition(v1, v2, culture);
|
||||
private static Func<T, T, CultureInfo?, bool> Swap<T>(Func<T, T, CultureInfo?, bool> condition) => (v1, v2, culture) => condition(v2, v1, culture);
|
||||
|
||||
private static Func<object, object, CultureInfo?, bool> GetStringEqCheck()
|
||||
{
|
||||
return (v1, v2, culture) => GetStringComparer(culture).Equals(ValueToString(v1, culture), ValueToString(v2, culture));
|
||||
}
|
||||
|
||||
private static string? ValueToString(object value, CultureInfo? culture)
|
||||
{
|
||||
return value switch
|
||||
{
|
||||
TimeSpan ts => ts.TotalMinutes.ToString("0", culture),
|
||||
IFormattable f => f.ToString(null, culture),
|
||||
_ => value.ToString()
|
||||
};
|
||||
}
|
||||
|
||||
private static StringComparer GetStringComparer(CultureInfo? culture)
|
||||
{
|
||||
return StringComparer.Create(culture ?? CultureInfo.CurrentCulture, ignoreCase: true);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Build a regular expression check. Uses culture-invariant matching for thread-safety and consistency.
|
||||
/// Applies a timeout to prevent regex patterns from causing excessive backtracking and blocking.
|
||||
/// Throws InvalidOperationException if the regex pattern is invalid or evaluation times out.
|
||||
/// </summary>
|
||||
/// <param name="exactName">The full tag string for context in error messages</param>
|
||||
/// <returns>check function to validate an object</returns>
|
||||
/// <exception cref="InvalidOperationException">Thrown when regex parsing fails or when regex matching times out, indicating faulty user input</exception>
|
||||
private static Func<object, object, CultureInfo?, bool> GetRegExpCheck(string exactName)
|
||||
{
|
||||
return (v1, v2, _) =>
|
||||
{
|
||||
var pattern = v2.ToString() ?? "";
|
||||
var regex = RegexCache.GetOrAdd(pattern, p =>
|
||||
{
|
||||
try
|
||||
{
|
||||
// Compile regex with timeout to prevent catastrophic backtracking
|
||||
return new Regex(p,
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled, RegexpCheckTimeout);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// If regex compilation fails, throw as faulty user input
|
||||
var errorMessage = BuildErrorMessage(exactName, p, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
});
|
||||
try
|
||||
{
|
||||
// CultureInfo parameter is intentionally ignored (discarded with _).
|
||||
// RegexOptions.CultureInvariant ensures culture-independent matching for predictable behavior.
|
||||
// This is preferred for template conditions because:
|
||||
// 1. Thread-safety: Regex operations are isolated and don't depend on thread-local culture
|
||||
// 2. Consistency: Template matches produce identical results regardless of system locale
|
||||
// 3. Predictability: Rules don't unexpectedly change based on user's OS settings
|
||||
//
|
||||
// Culture-sensitive matching would be problematic in cases like:
|
||||
// - Turkish locale: 'I' has different case folding (I ↔ ı vs. I ↔ i). Pattern "[i-z]" might match Turkish 'ı'.
|
||||
// - German locale: ß might be treated as equivalent to 'ss' during case-insensitive matching.
|
||||
// - Lithuanian locale: 'i' after 'ž' has an accent that affects sorting/matching.
|
||||
//
|
||||
// For naming templates, culture-invariant is the safer default.
|
||||
return regex.IsMatch(v1.ToString() ?? "");
|
||||
}
|
||||
catch (RegexMatchTimeoutException ex)
|
||||
{
|
||||
// Throw if regex evaluation times out, indicating faulty user input (e.g., catastrophic backtracking)
|
||||
var errorMessage = BuildErrorMessage(exactName, pattern, "Regular expression pattern evaluation timed out. Use a simpler pattern or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
private static string BuildErrorMessage(string exactName, string pattern, string errorType)
|
||||
{
|
||||
const int maxMessageLen = 200;
|
||||
|
||||
// Build a full message with the pattern
|
||||
var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}";
|
||||
|
||||
// Return a full message if it's within the character limit
|
||||
if (fullMsg.Length <= maxMessageLen) return fullMsg;
|
||||
|
||||
// Keep the error type and as much pattern as possible
|
||||
var maxPatternLen = maxMessageLen - errorType.Length - 23; // Account for ". Pattern starts with: "
|
||||
var trimmedPattern = pattern.Length > maxPatternLen ? pattern[..(maxPatternLen - 3)] + "..." : pattern;
|
||||
return $"{errorType}. Pattern starts with: {trimmedPattern}";
|
||||
}
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^(?>(?<op>(?<list_op> # anchor at start of line. Capture operator in <op>, <list_op> and <num_op>
|
||||
≡ | == | :equals: # - list operators: ≡ for checking if two lists contain the same items regardless of order
|
||||
| ∌ | !>> | ∌ | :not_contains: # - list operators: ∌ for checking if the first list does not contain any item of the second list
|
||||
| ∋ | >> | :contains: # - list operators: ∋ for checking if the first list contains all items of the second list
|
||||
| ∉ | !<< | ∉ | :not_in: # - list operators: ∉ for checking if the first list is not contained in the second list
|
||||
| ∈ | << | :in: # - list operators: ∈ for checking if the first list is contained in the second list
|
||||
| ⋂̸ | &&! | ⋂! | :disjoint: # - list operators: ⋂̸ for checking if the two lists are disjoint
|
||||
| ⋂ | && | :overlaps: # - list operators: ⋂ for checking if the two lists overlap in at least one item
|
||||
| ⊆ | <=< | :subset: # - list operators: ⊆ for checking if the first list is a subset of the second list (may be equal)
|
||||
| ⊇ | >=> | :superset: # - list operators: ⊇ for checking if the first list is a superset of the second list (may be equal)
|
||||
| ⊂ | <-< | :proper_subset: # - list operators: ⊂ for checking if the first list is a proper subset of the second list (not equal)
|
||||
| ⊃ | >-> | :proper_superset: # - list operators: ⊃ for checking if the first list is a proper superset of the second list (not equal)
|
||||
) | (?<num_op>
|
||||
\#!?= | ≠ | ≠ # - numerical operators: #= #!= ≠
|
||||
| \#[<>]=? # - numerical operators: #<= #>= #< #>
|
||||
| [<>]=? | ≤ | ≥ # - numerical operators: <= >= < > ≤ ≥
|
||||
) | [=!]?~ | !=? | =? # - string comparison operators including ~ for regexp, = and !=. No operator is like =
|
||||
)) \s*? # ignore space between operator and value
|
||||
(?<val>(?(num_op) # capture value in <val>
|
||||
(?:\d)+ # - numerical operators have to be followed by a number
|
||||
| (?:\\.|[^\\])+ ) # - string for comparison. May be empty. Capturing also all whitespace up to the end as this must have been escaped.
|
||||
)?$ # match to the end
|
||||
""")]
|
||||
private static partial Regex CheckRegex();
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -21,15 +23,16 @@ internal interface IClosingPropertyTag : IPropertyTag
|
||||
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
|
||||
}
|
||||
|
||||
public delegate bool Conditional<T>(ITemplateTag templateTag, T value, string condition);
|
||||
public delegate object? ValueProvider<in T>(ITemplateTag templateTag, T value, string condition, CultureInfo? culture);
|
||||
|
||||
public class ConditionalTagCollection<TClass> : TagCollection
|
||||
public delegate bool ConditionEvaluator(object? value1, object? value2, CultureInfo? culture);
|
||||
|
||||
public class ConditionalTagCollection<TClass>(bool caseSensitive = true) : TagCollection(typeof(TClass), caseSensitive)
|
||||
{
|
||||
public ConditionalTagCollection(bool caseSensative = true) : base(typeof(TClass), caseSensative) { }
|
||||
|
||||
/// <summary>
|
||||
/// Register a conditional tag.
|
||||
/// </summary>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the condition's <see cref="bool"/> value from <see cref="TClass"/></param>
|
||||
public void Add(ITemplateTag templateTag, Func<TClass, bool> propertyGetter)
|
||||
{
|
||||
@@ -41,10 +44,33 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
||||
/// <summary>
|
||||
/// Register a conditional tag.
|
||||
/// </summary>
|
||||
/// <param name="conditional">A <see cref="Conditional{TClass}"/> to get the condition's <see cref="bool"/> value</param>
|
||||
public void Add(ITemplateTag templateTag, Conditional<TClass> conditional)
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="valueProvider">A <see cref="ValueProvider{T}"/> to get the condition's value</param>
|
||||
/// <param name="conditionEvaluator">A <see cref="ConditionEvaluator"/> to evaluate the condition's value</param>
|
||||
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
|
||||
{
|
||||
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, conditional));
|
||||
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider, conditionEvaluator));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a conditional tag.
|
||||
/// </summary>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="valueProvider">A <see cref="ValueProvider{T}"/> to get the condition's value. The value will be evaluated by a check specified by the tag itself.</param>
|
||||
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider)
|
||||
{
|
||||
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Register a conditional tag.
|
||||
/// </summary>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="valueProvider1">A <see cref="ValueProvider{T}"/> to get the first condition's value. The values will be evaluated by a check specified by the tag itself.</param>
|
||||
/// <param name="valueProvider2">A <see cref="ValueProvider{T}"/> to get the second condition's value. The values will be evaluated by a check specified by the tag itself.</param>
|
||||
public void Add(ITemplateTag templateTag, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> valueProvider2)
|
||||
{
|
||||
AddPropertyTag(new ConditionalTag(templateTag, Options, Parameter, valueProvider1, valueProvider2));
|
||||
}
|
||||
|
||||
private class ConditionalTag : TagBase, IClosingPropertyTag
|
||||
@@ -52,32 +78,135 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
||||
public override Regex NameMatcher { get; }
|
||||
public Regex NameCloseMatcher { get; }
|
||||
|
||||
private Func<string?, Expression> CreateConditionExpression { get; }
|
||||
private Func<string, string?, string?, string?, Expression> CreateConditionExpression { get; }
|
||||
|
||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
|
||||
: base(templateTag, conditionExpression)
|
||||
{
|
||||
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}->", options);
|
||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||
CreateConditionExpression = _ => conditionExpression;
|
||||
var tagNameRe = TagNameForRegex();
|
||||
NameMatcher = new Regex($"^<(?<not>!)?{tagNameRe}->", options);
|
||||
NameCloseMatcher = new Regex($"^<-{tagNameRe}>", options);
|
||||
|
||||
CreateConditionExpression = (_, _, _, _) => conditionExpression;
|
||||
}
|
||||
|
||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, Conditional<TClass> conditional)
|
||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider, ConditionEvaluator conditionEvaluator)
|
||||
: base(templateTag, Expression.Constant(false))
|
||||
{
|
||||
NameMatcher = new Regex(@$"^<(!)?{templateTag.TagName}(?:\s+?(.*?)\s*?)?->", options);
|
||||
// <property> needs to match on at least one character, which is not a space
|
||||
NameMatcher = new Regex($"""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> to negate the condition
|
||||
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
|
||||
(?:\s+ # the following part is optional. If present it starts with some whitespace
|
||||
(?<property>.+?) # - capture the <property> non greedy so it won't end on whitespace, '[' or '-' (if match is possible)
|
||||
)? # end of optional property and check part
|
||||
\s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other
|
||||
"""
|
||||
, options);
|
||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||
|
||||
var target = conditional.Target is null ? null : Expression.Constant(conditional.Target);
|
||||
CreateConditionExpression = condition
|
||||
=> Expression.Call(
|
||||
conditional.Target is null ? null : Expression.Constant(conditional.Target),
|
||||
conditional.Method,
|
||||
Expression.Constant(templateTag),
|
||||
parameter,
|
||||
Expression.Constant(condition));
|
||||
CreateConditionExpression = (_, property, _, _)
|
||||
=> ConditionEvaluatorCall(conditionEvaluator,
|
||||
ValueProviderCall(templateTag, parameter, valueProvider, property),
|
||||
Expression.Constant(null));
|
||||
}
|
||||
|
||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider)
|
||||
: base(templateTag, Expression.Constant(false))
|
||||
{
|
||||
// <property> needs to match on at least one character, which is not a space.
|
||||
// though we will capture the group named `check_or_op` enclosed in [] at the end of the tag, the property itself might also have a [] part for formatting purposes
|
||||
NameMatcher = new Regex($"""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> to negate the condition
|
||||
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
|
||||
(?:\s+ # the following part is optional. If present it starts with some whitespace
|
||||
(?<property>.+? # - capture the <property> non greedy so it won't end on whitespace, '[' or '-' (if match is possible)
|
||||
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname [foobar]->" would be matchable.
|
||||
(?:\s*\[\s* # optional check details enclosed in '[' and ']'. Check shall start with an operator. So match whitespace first
|
||||
(?<check_or_op> # - capture inner part as <check_or_op>
|
||||
(?:\\. # - '\' escapes always the next character. Especially further '\' and the closing ']'
|
||||
|[^\\\]])* ) # - match any character except '\' and ']'. check_or_op may end in whitespace!
|
||||
\])? # - closing the check_or_op part
|
||||
)? # end of optional property and check_or_op part
|
||||
\s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other
|
||||
"""
|
||||
, options);
|
||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||
|
||||
CreateConditionExpression = (exactName, property, checkString, _) =>
|
||||
{
|
||||
var (value, conditionEvaluator) = CompareCondition.GetPredicateAndValue(exactName, checkString);
|
||||
return ConditionEvaluatorCall(conditionEvaluator,
|
||||
ValueProviderCall(templateTag, parameter, valueProvider, property),
|
||||
BuildArgument(value, conditionEvaluator.Method.GetParameters()[1].ParameterType));
|
||||
};
|
||||
}
|
||||
|
||||
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, ParameterExpression parameter, ValueProvider<TClass> valueProvider1, ValueProvider<TClass> valueProvider2)
|
||||
: base(templateTag, Expression.Constant(false))
|
||||
{
|
||||
NameMatcher = new Regex($"""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^<(?<not>!)? # tags start with a '<'. Conditionals allow an optional ! captured in <not> to negate the condition
|
||||
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
|
||||
\s+ # Separate the following with whitespace
|
||||
(?<property> # capture the <property>
|
||||
'(?:[^']|'')*' # - allow 'string' to be included in the format, with '' being an escaped ' character
|
||||
| "(?:[^"]|"")*" # - allow "string" to be included in the format, with "" being an escaped " character
|
||||
| (?: \[ (?: \\. # - properties may have optional formatting details enclosed in '[' and ']'. '\' escapes always the next character
|
||||
| [^\\\]] # unescaped characters except ']' and '\' are allowed in the formatting details
|
||||
)* \] # closing the formatting details part
|
||||
| . )+? # - match any character to form the property name. Capture non greedy so it won't match the operator part.
|
||||
(?<!\s)) # - don't let <property> end with a whitepace. Otherwise "<tagname = tag2->" would be matchable.
|
||||
\s+ # Separate the following operand with whitespace
|
||||
(?<check_or_op> # capture operator in <check_or_op>
|
||||
[\#!≡=≠~<>≤≥&∉∌∈∌⋂⊆⊇⊂⊃-]+ # allow a wide range of operators, all non alphanumeric so that no operator is confused as property
|
||||
| :[a-z_]+: # allow :named: operators for readability, e.g. :contains:
|
||||
) \s+ # ignore space between operator and second property
|
||||
(?<second_property>.+? # - capture the <second_property> non greedy so it won't end on whitespace
|
||||
(?<!\s)) # - don't let <second_property> end with a whitepace. Otherwise "<tagname tag1 = ->" would be matchable.
|
||||
\s*-> # Opening tags end with '->' and closing tags begin with '<-', so both sides visually point toward each other
|
||||
"""
|
||||
, options);
|
||||
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
|
||||
|
||||
CreateConditionExpression = (exactName, property1, checkString, property2)
|
||||
=> ConditionEvaluatorCall(CompareCondition.GetPredicate(exactName, checkString),
|
||||
ValueProviderCall(templateTag, parameter, valueProvider1, property1),
|
||||
ValueProviderCall(templateTag, parameter, valueProvider2, property2));
|
||||
}
|
||||
|
||||
private static MethodCallExpression ConditionEvaluatorCall(ConditionEvaluator conditionEvaluator, Expression valueExpression1, Expression valueExpression2)
|
||||
{
|
||||
return Expression.Call(
|
||||
conditionEvaluator.Target is null ? null : Expression.Constant(conditionEvaluator.Target),
|
||||
conditionEvaluator.Method,
|
||||
valueExpression1,
|
||||
valueExpression2,
|
||||
CultureParameter);
|
||||
}
|
||||
|
||||
private static MethodCallExpression ValueProviderCall(ITemplateTag templateTag, ParameterExpression parameter, ValueProvider<TClass> valueProvider, string? property)
|
||||
{
|
||||
return Expression.Call(
|
||||
valueProvider.Target is null ? null : Expression.Constant(valueProvider.Target),
|
||||
valueProvider.Method,
|
||||
Expression.Constant(templateTag),
|
||||
parameter,
|
||||
Expression.Constant(property),
|
||||
CultureParameter);
|
||||
}
|
||||
|
||||
private static Expression BuildArgument(object value, Type targetType)
|
||||
{
|
||||
var constant = Expression.Constant(value, value.GetType());
|
||||
return constant.Type == targetType ? constant : Expression.Convert(constant, targetType);
|
||||
}
|
||||
|
||||
// without any special check, only the existence of the property is checked. Strings need to be non-empty.
|
||||
|
||||
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
|
||||
{
|
||||
var match = NameCloseMatcher.Match(templateString);
|
||||
@@ -93,13 +222,14 @@ public class ConditionalTagCollection<TClass> : TagCollection
|
||||
return false;
|
||||
}
|
||||
|
||||
protected override Expression GetTagExpression(string exactName, string[] extraData)
|
||||
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData, OutputType outputType)
|
||||
{
|
||||
if (extraData.Length is not (1 or 2) || extraData[0] is not ("!" or "") || extraData.Length == 2 && string.IsNullOrWhiteSpace(extraData[1]))
|
||||
return Expression.Constant(false);
|
||||
|
||||
var getBool = extraData.Length == 2 ? CreateConditionExpression(extraData[1]) : CreateConditionExpression(null);
|
||||
return extraData[0] == "!" ? Expression.Not(getBool) : getBool;
|
||||
var getBool = CreateConditionExpression(
|
||||
exactName,
|
||||
matchData.GetValueOrDefault("property")?.Value,
|
||||
matchData.GetValueOrDefault("check_or_op")?.ValueOrNull(),
|
||||
matchData.GetValueOrDefault("second_property")?.ValueOrNull());
|
||||
return matchData["not"].Success ? Expression.Not(getBool) : getBool;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,20 +10,20 @@ public class NamingTemplate
|
||||
{
|
||||
public string TemplateText { get; private set; } = string.Empty;
|
||||
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
|
||||
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
|
||||
public IEnumerable<string> Warnings => errors.Concat(warnings);
|
||||
public IEnumerable<string> Errors => errors;
|
||||
public IEnumerable<ITemplateTag> TagsRegistered => _tagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
|
||||
public IEnumerable<string> Warnings => _errors.Concat(_warnings);
|
||||
public IEnumerable<string> Errors => _errors;
|
||||
|
||||
private Delegate? templateToString;
|
||||
private readonly List<string> warnings = new();
|
||||
private readonly List<string> errors = new();
|
||||
private readonly IEnumerable<TagCollection> TagCollections;
|
||||
private readonly List<ITemplateTag> _tagsInUse = new();
|
||||
private Delegate? _templateToString;
|
||||
private readonly List<string> _warnings = [];
|
||||
private readonly List<string> _errors = [];
|
||||
private readonly List<TagCollection> _tagCollections;
|
||||
private readonly List<ITemplateTag> _tagsInUse = [];
|
||||
|
||||
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
|
||||
public const string WARNING_EMPTY = "Template is empty.";
|
||||
public const string WARNING_WHITE_SPACE = "Template is white space.";
|
||||
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
|
||||
public const string ErrorNullIsInvalid = "Null template is invalid.";
|
||||
public const string WarningEmpty = "Template is empty.";
|
||||
public const string WarningWhiteSpace = "Template is white space.";
|
||||
public const string WarningNoTags = "Should use tags. Eg: <title>";
|
||||
|
||||
/// <summary>
|
||||
/// Invoke the <see cref="NamingTemplate"/>
|
||||
@@ -31,19 +31,37 @@ public class NamingTemplate
|
||||
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
|
||||
public TemplatePart Evaluate(params object?[] propertyClasses)
|
||||
{
|
||||
if (templateToString is null)
|
||||
if (_templateToString is null)
|
||||
throw new InvalidOperationException();
|
||||
|
||||
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
|
||||
// First parameter is "this", so ignore it.
|
||||
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
|
||||
var delegateArgTypes = _templateToString.Method.GetParameters().Skip(1).Select(p => p.ParameterType).ToList();
|
||||
var delegateArgs = new object?[delegateArgTypes.Count];
|
||||
|
||||
object?[] args = delegateArgTypes.Join(propertyClasses, o => o.ParameterType, i => i?.GetType(), (_, i) => i).ToArray();
|
||||
var availableObjects = propertyClasses.Where(pc => pc is not null).Cast<object>().ToList();
|
||||
for (var i = 0; i < delegateArgTypes.Count; i++)
|
||||
{
|
||||
var p = delegateArgTypes[i];
|
||||
var index = availableObjects.FindIndex(pc => p.IsInstanceOfType(pc));
|
||||
if (index < 0)
|
||||
{
|
||||
if (CanBeNull(p))
|
||||
delegateArgs[i] = null;
|
||||
else
|
||||
throw new ArgumentException(
|
||||
$"No matching object found for parameter type {p.Name}. Available objects: {string.Join(", ", availableObjects.Select(o => o.GetType().Name))}");
|
||||
}
|
||||
else
|
||||
{
|
||||
var candidate = availableObjects[index];
|
||||
availableObjects.RemoveAt(index);
|
||||
availableObjects.Add(candidate); // Re-add to the end to allow reuse if needed later
|
||||
delegateArgs[i] = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
if (args.Length != delegateArgTypes.Count())
|
||||
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
|
||||
|
||||
return (templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart;
|
||||
return (_templateToString.DynamicInvoke(delegateArgs) as TemplatePart)!.FirstPart;
|
||||
}
|
||||
|
||||
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
|
||||
@@ -52,35 +70,37 @@ public class NamingTemplate
|
||||
/// properties registered to match to the <paramref name="template"/></param>
|
||||
public static NamingTemplate Parse(string? template, IEnumerable<TagCollection> tagCollections)
|
||||
{
|
||||
var namingTemplate = new NamingTemplate(tagCollections);
|
||||
var listOfTagCollections = tagCollections.ToList();
|
||||
var namingTemplate = new NamingTemplate(listOfTagCollections);
|
||||
try
|
||||
{
|
||||
BinaryNode intermediate = namingTemplate.IntermediateParse(template);
|
||||
Expression evalTree = GetExpressionTree(intermediate);
|
||||
var intermediate = namingTemplate.IntermediateParse(template);
|
||||
var evalTree = GetExpressionTree(intermediate);
|
||||
|
||||
namingTemplate.templateToString = Expression.Lambda(evalTree, tagCollections.Select(tc => tc.Parameter)).Compile();
|
||||
namingTemplate._templateToString = Expression.Lambda(evalTree, listOfTagCollections.Select(tc => tc.Parameter).Append(TagCollection.CultureParameter)).Compile();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
namingTemplate.errors.Add(ex.Message);
|
||||
namingTemplate._errors.Add(ex.Message);
|
||||
}
|
||||
return namingTemplate;
|
||||
}
|
||||
|
||||
private NamingTemplate(IEnumerable<TagCollection> properties)
|
||||
private NamingTemplate(List<TagCollection> properties)
|
||||
{
|
||||
TagCollections = properties;
|
||||
_tagCollections = properties;
|
||||
}
|
||||
|
||||
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
|
||||
private static Expression GetExpressionTree(BinaryNode? node)
|
||||
{
|
||||
if (node is null) return TemplatePart.Blank;
|
||||
else if (node.IsValue) return node.Expression;
|
||||
else if (node.IsConditional) return Expression.Condition(node.Expression, concatExpression(node), TemplatePart.Blank);
|
||||
else return concatExpression(node);
|
||||
if (node.IsValue) return node.Expression;
|
||||
return node.IsConditional
|
||||
? Expression.Condition(node.Expression, ConcatExpression(node), TemplatePart.Blank)
|
||||
: ConcatExpression(node);
|
||||
|
||||
static Expression concatExpression(BinaryNode node)
|
||||
static Expression ConcatExpression(BinaryNode node)
|
||||
=> TemplatePart.CreateConcatenation(GetExpressionTree(node.LeftChild), GetExpressionTree(node.RightChild));
|
||||
}
|
||||
|
||||
@@ -88,23 +108,23 @@ public class NamingTemplate
|
||||
private BinaryNode IntermediateParse(string? templateString)
|
||||
{
|
||||
if (templateString is null)
|
||||
throw new ArgumentException(ERROR_NULL_IS_INVALID);
|
||||
else if (string.IsNullOrEmpty(templateString))
|
||||
warnings.Add(WARNING_EMPTY);
|
||||
throw new ArgumentException(ErrorNullIsInvalid);
|
||||
if (string.IsNullOrEmpty(templateString))
|
||||
_warnings.Add(WarningEmpty);
|
||||
else if (string.IsNullOrWhiteSpace(templateString))
|
||||
warnings.Add(WARNING_WHITE_SPACE);
|
||||
_warnings.Add(WarningWhiteSpace);
|
||||
|
||||
TemplateText = templateString;
|
||||
|
||||
BinaryNode topNode = BinaryNode.CreateRoot();
|
||||
BinaryNode? currentNode = topNode;
|
||||
List<char> literalChars = new();
|
||||
var topNode = BinaryNode.CreateRoot();
|
||||
var currentNode = topNode;
|
||||
List<char> literalChars = [];
|
||||
|
||||
while (templateString.Length > 0)
|
||||
{
|
||||
if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression))
|
||||
if (StartsWith(templateString, OutputType.String, out var exactPropertyName, out var propertyTag, out var valueExpression))
|
||||
{
|
||||
checkAndAddLiterals();
|
||||
CheckAndAddLiterals();
|
||||
|
||||
if (propertyTag is IClosingPropertyTag)
|
||||
currentNode = currentNode.AddNewNode(BinaryNode.CreateConditional(propertyTag.TemplateTag, valueExpression));
|
||||
@@ -118,25 +138,25 @@ public class NamingTemplate
|
||||
}
|
||||
else if (StartsWithClosing(templateString, out exactPropertyName, out var closingPropertyTag))
|
||||
{
|
||||
checkAndAddLiterals();
|
||||
CheckAndAddLiterals();
|
||||
|
||||
BinaryNode? lastParenth = currentNode;
|
||||
var lastParent = currentNode;
|
||||
|
||||
while (lastParenth?.IsConditional is false)
|
||||
lastParenth = lastParenth.Parent;
|
||||
while (lastParent?.IsConditional is false)
|
||||
lastParent = lastParent.Parent;
|
||||
|
||||
if (lastParenth?.Parent is null)
|
||||
if (lastParent?.Parent is null)
|
||||
{
|
||||
warnings.Add($"Missing <{closingPropertyTag.TemplateTag.TagName}-> open conditional.");
|
||||
_warnings.Add($"Missing <{closingPropertyTag.TemplateTag.TagName}-> open conditional.");
|
||||
break;
|
||||
}
|
||||
else if (lastParenth.Name != closingPropertyTag.TemplateTag.TagName)
|
||||
else if (lastParent.Name != closingPropertyTag.TemplateTag.TagName)
|
||||
{
|
||||
warnings.Add($"Missing <-{lastParenth.Name}> closing conditional.");
|
||||
_warnings.Add($"Missing <-{lastParent.Name}> closing conditional.");
|
||||
break;
|
||||
}
|
||||
|
||||
currentNode = lastParenth.Parent;
|
||||
currentNode = lastParent.Parent;
|
||||
templateString = templateString[exactPropertyName.Length..];
|
||||
}
|
||||
else
|
||||
@@ -147,22 +167,23 @@ public class NamingTemplate
|
||||
templateString = templateString[1..];
|
||||
}
|
||||
}
|
||||
checkAndAddLiterals();
|
||||
|
||||
CheckAndAddLiterals();
|
||||
|
||||
//Check for any conditionals that haven't been closed
|
||||
while (currentNode is not null)
|
||||
{
|
||||
if (currentNode.IsConditional)
|
||||
warnings.Add($"Missing <-{currentNode.Name}> closing conditional.");
|
||||
_warnings.Add($"Missing <-{currentNode.Name}> closing conditional.");
|
||||
currentNode = currentNode.Parent;
|
||||
}
|
||||
|
||||
if (!_tagsInUse.Any())
|
||||
warnings.Add(WARNING_NO_TAGS);
|
||||
_warnings.Add(WarningNoTags);
|
||||
|
||||
return topNode;
|
||||
|
||||
void checkAndAddLiterals()
|
||||
void CheckAndAddLiterals()
|
||||
{
|
||||
if (literalChars.Count != 0)
|
||||
{
|
||||
@@ -172,11 +193,12 @@ public class NamingTemplate
|
||||
}
|
||||
}
|
||||
|
||||
private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression)
|
||||
private bool StartsWith(string template, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag,
|
||||
[NotNullWhen(true)] out Expression? valueExpression)
|
||||
{
|
||||
foreach (var pc in TagCollections)
|
||||
foreach (var pc in _tagCollections)
|
||||
{
|
||||
if (pc.StartsWith(template, out exactName, out propertyTag, out valueExpression))
|
||||
if (pc.StartsWith(template, outputType, out exactName, out propertyTag, out valueExpression))
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -188,7 +210,7 @@ public class NamingTemplate
|
||||
|
||||
private bool StartsWithClosing(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
|
||||
{
|
||||
foreach (var pc in TagCollections)
|
||||
foreach (var pc in _tagCollections)
|
||||
{
|
||||
if (pc.StartsWithClosing(template, out exactName, out closingPropertyTag))
|
||||
return true;
|
||||
@@ -206,8 +228,8 @@ public class NamingTemplate
|
||||
public BinaryNode? RightChild { get; private set; }
|
||||
public BinaryNode? LeftChild { get; private set; }
|
||||
public Expression Expression { get; }
|
||||
public bool IsConditional { get; private init; } = false;
|
||||
public bool IsValue { get; private init; } = false;
|
||||
public bool IsConditional { get; private init; }
|
||||
public bool IsValue { get; private init; }
|
||||
|
||||
public static BinaryNode CreateRoot() => new("Root", Expression.Empty());
|
||||
|
||||
@@ -251,7 +273,7 @@ public class NamingTemplate
|
||||
|
||||
public BinaryNode AddNewNode(BinaryNode newNode)
|
||||
{
|
||||
BinaryNode currentNode = this;
|
||||
var currentNode = this;
|
||||
|
||||
if (LeftChild is null)
|
||||
{
|
||||
@@ -273,4 +295,6 @@ public class NamingTemplate
|
||||
return newNode.IsConditional ? newNode : currentNode;
|
||||
}
|
||||
}
|
||||
|
||||
private static bool CanBeNull(Type type) => !type.IsValueType || Nullable.GetUnderlyingType(type) != null;
|
||||
}
|
||||
@@ -1,32 +1,33 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
using PF = FileManager.NamingTemplate.CommonFormatters;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
|
||||
|
||||
public class PropertyTagCollection<TClass> : TagCollection
|
||||
{
|
||||
private readonly Dictionary<Type, MulticastDelegate> defaultFormatters = new();
|
||||
private readonly Dictionary<Type, MulticastDelegate> _defaultFormatters = new();
|
||||
|
||||
public PropertyTagCollection(bool caseSensative = true, params MulticastDelegate[] defaultFormatters) : base(typeof(TClass), caseSensative)
|
||||
public PropertyTagCollection(bool caseSensitive = true, params MulticastDelegate[] defaultFormatters) : base(typeof(TClass), caseSensitive)
|
||||
{
|
||||
foreach (var formatter in defaultFormatters)
|
||||
{
|
||||
var parameters = formatter.Method.GetParameters();
|
||||
|
||||
if (formatter.Method.ReturnType != typeof(string)
|
||||
|| parameters.Length != 3
|
||||
|| parameters.Length != 4
|
||||
|| parameters[0].ParameterType != typeof(ITemplateTag)
|
||||
|| parameters[2].ParameterType != typeof(string))
|
||||
throw new ArgumentException($"{nameof(defaultFormatters)} must have a signature of [{nameof(String)} PropertyFormatter<T>({nameof(ITemplateTag)}, T, {nameof(String)})]");
|
||||
|| parameters[2].ParameterType != typeof(string)
|
||||
|| !typeof(CultureInfo).IsAssignableFrom(parameters[3].ParameterType))
|
||||
throw new ArgumentException(
|
||||
$"{nameof(defaultFormatters)} must have a signature of [{nameof(String)} PropertyFormatter<T>({nameof(ITemplateTag)}, T, {nameof(String)}, {nameof(CultureInfo)})]");
|
||||
|
||||
this.defaultFormatters[parameters[1].ParameterType] = formatter;
|
||||
_defaultFormatters[parameters[1].ParameterType] = formatter;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,57 +35,105 @@ public class PropertyTagCollection<TClass> : TagCollection
|
||||
/// Register a nullable value type <typeparamref name="TClass"/> property.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
|
||||
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
||||
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
|
||||
/// and a formatting string and returns the value the formatted string. If <c>null</c>, use the default
|
||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PF.PropertyFormatter<TProperty, string>? formatter = null)
|
||||
where TProperty : struct
|
||||
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
||||
|
||||
/// <summary>
|
||||
/// Register a <typeparamref name="TClass"/> property
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
|
||||
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
||||
/// and a formatting string and returns the value formatted to string. If <c>null</c>, use the default
|
||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TProperty, string>? formatter = null)
|
||||
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
||||
|
||||
/// <summary>
|
||||
/// Register a nullable value type <typeparamref name="TClass"/> property.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <typeparam name="TPreFormatted"></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
|
||||
/// <param name="preFormatter">A Func used for first filtering and formatting. The result might be a <see cref="string"/></param>
|
||||
/// <param name="finalizer">This Func assures a string result</param>
|
||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/>
|
||||
public void Add<TProperty, TPreFormatted>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PF.PropertyFormatter<TProperty, TPreFormatted> preFormatter,
|
||||
PF.PropertyFinalizer<TPreFormatted> finalizer)
|
||||
where TProperty : struct
|
||||
=> RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer);
|
||||
|
||||
/// <summary>
|
||||
/// Register a nullable value type <typeparamref name="TClass"/> property.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <typeparam name="TPreFormatted"></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
|
||||
/// <param name="preFormatter">A Func used for first filtering and formatting. The result might be a <see cref="string"/></param>
|
||||
/// <param name="finalizer">This Func assures a string result</param>
|
||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/>
|
||||
public void Add<TProperty, TPreFormatted>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TProperty, TPreFormatted> preFormatter,
|
||||
PF.PropertyFinalizer<TPreFormatted> finalizer)
|
||||
=> RegisterWithPreFormatter(templateTag, propertyGetter, preFormatter, finalizer);
|
||||
|
||||
/// <summary>
|
||||
/// Register a nullable value type <typeparamref name="TClass"/> property.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param>
|
||||
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param>
|
||||
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returns a string</param>
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, Func<TProperty, string> toString)
|
||||
where TProperty : struct
|
||||
=> RegisterWithToString(templateTag, propertyGetter, toString);
|
||||
|
||||
/// <summary>
|
||||
/// Register a <typeparamref name="TClass"/> property
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <param name="propertyGetter">A Func to get the property value from <see cref="TClass"/></param>
|
||||
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
|
||||
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
|
||||
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
|
||||
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
|
||||
|
||||
/// <summary>
|
||||
/// Register a <typeparamref name="TClass"/> property.
|
||||
/// </summary>
|
||||
/// <typeparam name="TProperty">Type of the property from <see cref="TClass"/></typeparam>
|
||||
/// <param name="templateTag"></param>
|
||||
/// <param name="propertyGetter">A Func to get the string property from <see cref="TClass"/></param>
|
||||
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returnes a string</param>
|
||||
/// <param name="toString">ToString function that accepts the <typeparamref name="TProperty"/> property and returns a string</param>
|
||||
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, Func<TProperty, string> toString)
|
||||
=> RegisterWithToString(templateTag, propertyGetter, toString);
|
||||
|
||||
private void RegisterWithFormatter<TProperty, TPropertyValue>
|
||||
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue>? formatter)
|
||||
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, string>? formatter)
|
||||
{
|
||||
formatter ??= GetDefaultFormatter<TPropertyValue>();
|
||||
|
||||
if (formatter is null)
|
||||
RegisterWithToString<TProperty, TPropertyValue>(templateTag, propertyGetter, ToStringFunc);
|
||||
else
|
||||
RegisterWithFormatters(templateTag, propertyGetter, formatter, PF.StringFinalizer, PF.ToFinalizer(formatter));
|
||||
}
|
||||
|
||||
private void RegisterWithPreFormatter<TProperty, TPropertyValue, TPreFormatted>
|
||||
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
|
||||
PF.PropertyFinalizer<TPreFormatted> finalizer)
|
||||
{
|
||||
var formatter = PF.ToPropertyFormatter(preFormatter, finalizer);
|
||||
RegisterWithFormatters(templateTag, propertyGetter, preFormatter, finalizer, formatter);
|
||||
}
|
||||
|
||||
private void RegisterWithFormatters<TProperty, TPropertyValue, TPreFormatted>
|
||||
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
|
||||
PF.PropertyFinalizer<TPreFormatted> finalizer, PF.PropertyFinalizer<TPropertyValue> formatter)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
|
||||
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
|
||||
|
||||
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
||||
formatter ??= GetDefaultFormatter<TPropertyValue>();
|
||||
|
||||
if (formatter is null)
|
||||
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
|
||||
else
|
||||
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
|
||||
AddPropertyTag(new PropertyTag<TPropertyValue, TPreFormatted>(templateTag, Options, expr, preFormatter, finalizer, formatter));
|
||||
}
|
||||
|
||||
private void RegisterWithToString<TProperty, TPropertyValue>
|
||||
@@ -94,17 +143,17 @@ public class PropertyTagCollection<TClass> : TagCollection
|
||||
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
|
||||
|
||||
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
|
||||
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, toString ?? ToStringFunc));
|
||||
AddPropertyTag(new PropertyTag<TPropertyValue, string>(templateTag, Options, expr, toString));
|
||||
}
|
||||
|
||||
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
|
||||
|
||||
private PropertyFormatter<T>? GetDefaultFormatter<T>()
|
||||
private PF.PropertyFormatter<T, string>? GetDefaultFormatter<T>()
|
||||
{
|
||||
try
|
||||
{
|
||||
var del = defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value;
|
||||
return del is null ? null : Delegate.CreateDelegate(typeof(PropertyFormatter<T>), del.Target, del.Method) as PropertyFormatter<T>;
|
||||
var del = _defaultFormatters.FirstOrDefault(kvp => kvp.Key == typeof(T)).Value;
|
||||
return del is null ? null : Delegate.CreateDelegate(typeof(PF.PropertyFormatter<T, string>), del.Target, del.Method) as PF.PropertyFormatter<T, string>;
|
||||
}
|
||||
catch { return null; }
|
||||
}
|
||||
@@ -114,42 +163,90 @@ public class PropertyTagCollection<TClass> : TagCollection
|
||||
/// </summary>
|
||||
/// <param name="tagName">Name of the tag value to get</param>
|
||||
/// <param name="object">The property class from which the tag's value is read</param>
|
||||
/// <param name="value"><paramref name="tagName"/>'s string value if it is in this collection, otherwise null</param>
|
||||
/// <param name="culture"></param>
|
||||
/// <param name="value"><paramref name="tagName"/>'s object value if it is in this collection, otherwise null</param>
|
||||
/// <returns>True if the <paramref name="tagName"/> is in this collection, otherwise false</returns>
|
||||
public bool TryGetValue(string tagName, TClass @object, [NotNullWhen(true)] out string? value)
|
||||
public bool TryGetObject(string tagName, TClass @object, CultureInfo? culture, out object? value)
|
||||
{
|
||||
value = null;
|
||||
|
||||
if (!StartsWith($"<{tagName}>", out var exactName, out var propertyTag, out var valueExpression))
|
||||
if (!StartsWith($"<{tagName}>", OutputType.Object, out _, out _, out var valueExpression))
|
||||
return false;
|
||||
|
||||
var func = Expression.Lambda<Func<TClass, string>>(valueExpression, Parameter).Compile();
|
||||
value = func(@object);
|
||||
var func = Expression.Lambda<Func<TClass, CultureInfo?, object?>>(valueExpression, Parameter, CultureParameter).Compile();
|
||||
value = func(@object, culture);
|
||||
return true;
|
||||
}
|
||||
|
||||
private class PropertyTag<TPropertyValue> : TagBase
|
||||
private class PropertyTag<TPropertyValue, TPreFormatted> : TagBase
|
||||
{
|
||||
public override Regex NameMatcher { get; }
|
||||
private Func<Expression, string, Expression> CreateToStringExpression { get; }
|
||||
private Func<Expression, string?, Expression> CreateToStringExpression { get; }
|
||||
private Func<Expression, string?, Expression> CreateToObjectExpression { get; } = (expVal, _) => expVal;
|
||||
|
||||
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)
|
||||
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PF.PropertyFormatter<TPropertyValue, TPreFormatted> preFormatter,
|
||||
PF.PropertyFinalizer<TPreFormatted> finalizer, PF.PropertyFinalizer<TPropertyValue> formatter)
|
||||
: base(templateTag, propertyGetter)
|
||||
{
|
||||
NameMatcher = new Regex(@$"^<{templateTag.TagName.Replace(" ", "\\s*?")}\s*?(?:\[([^\[\]]*?)\]\s*?)?>", options);
|
||||
NameMatcher = new Regex($"""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^< # tags start with a '<'
|
||||
{TagNameForRegex()} # next the tagname needs to be matched with space being made optional. Also escape all '#'
|
||||
(?:\s* # optional whitespace
|
||||
\[ (?<format> (?: # optional format details enclosed in '[' and ']'. Capture inner part as <format>.
|
||||
\\. # - '\' escapes always the next character. Especially further '\' and the closing ']'
|
||||
| '[^']*' # - allow 'string' to be included in the format
|
||||
| "[^"]*" # - allow "string" to be included in the format
|
||||
| [^'"\\\]] # - match any character except '\' and ']'. Format may end in whitespace!
|
||||
) * ) # extend up to the next ']'
|
||||
\] # - closing the format part
|
||||
)?\s*> # Tags end with '>'
|
||||
"""
|
||||
, options);
|
||||
|
||||
// if no format is specified, we can directly use the expVal from the property-getter as object value,
|
||||
// otherwise we need to call the preFormatter with the format string and culture info to get the formatted value as object.
|
||||
CreateToObjectExpression = (expVal, format) =>
|
||||
format is null
|
||||
? expVal
|
||||
: Expression.Call(
|
||||
preFormatter.Target is null ? null : Expression.Constant(preFormatter.Target),
|
||||
preFormatter.Method,
|
||||
Expression.Constant(templateTag),
|
||||
expVal,
|
||||
Expression.Constant(format),
|
||||
CultureParameter);
|
||||
|
||||
// if no format is specified, we can use the specific formatter to format the value to string directly,
|
||||
// otherwise we need to call the preFormatter with the format string and culture info to get the formatted value as object,
|
||||
// and then call the finalizer to get the final string value.
|
||||
CreateToStringExpression = (expVal, format) =>
|
||||
Expression.Call(
|
||||
formatter.Target is null ? null : Expression.Constant(formatter.Target),
|
||||
formatter.Method,
|
||||
Expression.Constant(templateTag),
|
||||
expVal,
|
||||
Expression.Constant(format));
|
||||
format is null
|
||||
? Expression.Call(
|
||||
formatter.Target is null ? null : Expression.Constant(formatter.Target),
|
||||
formatter.Method,
|
||||
Expression.Constant(templateTag),
|
||||
expVal,
|
||||
CultureParameter)
|
||||
: Expression.Call(
|
||||
finalizer.Target is null ? null : Expression.Constant(finalizer.Target),
|
||||
finalizer.Method,
|
||||
Expression.Constant(templateTag),
|
||||
Expression.Call(
|
||||
preFormatter.Target is null ? null : Expression.Constant(preFormatter.Target),
|
||||
preFormatter.Method,
|
||||
Expression.Constant(templateTag),
|
||||
expVal,
|
||||
Expression.Constant(format),
|
||||
CultureParameter),
|
||||
CultureParameter);
|
||||
}
|
||||
|
||||
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, Func<TPropertyValue, string> toString)
|
||||
: base(templateTag, propertyGetter)
|
||||
{
|
||||
NameMatcher = new Regex(@$"^<{templateTag.TagName.Replace(" ", "\\s*?")}>", options);
|
||||
NameMatcher = new Regex(@$"^<{TagNameForRegex()}>", options);
|
||||
|
||||
CreateToStringExpression = (expVal, _) =>
|
||||
Expression.Call(
|
||||
toString.Target is null ? null : Expression.Constant(toString.Target),
|
||||
@@ -157,27 +254,41 @@ public class PropertyTagCollection<TClass> : TagCollection
|
||||
expVal);
|
||||
}
|
||||
|
||||
protected override Expression GetTagExpression(string exactName, string[] extraData)
|
||||
protected override Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData, OutputType outputType)
|
||||
{
|
||||
if (extraData.Length is not (0 or 1))
|
||||
return Expression.Constant(exactName);
|
||||
var formatString = matchData.GetValueOrDefault("format")?.ValueOrNull();
|
||||
var isReferenceType = !ReturnType.IsValueType;
|
||||
var isNullableValueType = Nullable.GetUnderlyingType(ReturnType) is not null;
|
||||
|
||||
string formatString = extraData.Length == 1 ? extraData[0] : "";
|
||||
Expression isNullExpression = isReferenceType
|
||||
? Expression.Equal(ValueExpression, Expression.Constant(null))
|
||||
: isNullableValueType
|
||||
? Expression.Not(Expression.PropertyOrField(ValueExpression, "HasValue"))
|
||||
: Expression.Constant(false);
|
||||
|
||||
Expression toStringExpression
|
||||
= !ReturnType.IsValueType
|
||||
? Expression.Condition(
|
||||
Expression.Equal(ValueExpression, Expression.Constant(null)),
|
||||
Expression.Constant(""),
|
||||
CreateToStringExpression(ValueExpression, formatString))
|
||||
: Nullable.GetUnderlyingType(ReturnType) is null
|
||||
? CreateToStringExpression(ValueExpression, formatString)
|
||||
: Expression.Condition(
|
||||
Expression.PropertyOrField(ValueExpression, "HasValue"),
|
||||
CreateToStringExpression(Expression.PropertyOrField(ValueExpression, "Value"), formatString),
|
||||
Expression.Constant(""));
|
||||
// formatters are defined for non-nullable items <see cref="int"/>, <see cref="DateTime"/> and not for <see cref="int?"/> ...
|
||||
var formattableValueExpression = isNullableValueType
|
||||
? Expression.PropertyOrField(ValueExpression, "Value")
|
||||
: ValueExpression;
|
||||
|
||||
return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName)));
|
||||
if (outputType == OutputType.String)
|
||||
{
|
||||
Expression toStringExpression =
|
||||
Expression.Condition(
|
||||
isNullExpression,
|
||||
Expression.Constant(null, typeof(string)),
|
||||
CreateToStringExpression(formattableValueExpression, formatString));
|
||||
|
||||
return Expression.TryCatch(toStringExpression, Expression.Catch(typeof(Exception), Expression.Constant(exactName)));
|
||||
}
|
||||
|
||||
Expression toObjectExpression =
|
||||
Expression.Condition(
|
||||
isNullExpression,
|
||||
Expression.Constant(null, typeof(object)),
|
||||
Expression.Convert(CreateToObjectExpression(formattableValueExpression, formatString), typeof(object)));
|
||||
|
||||
return Expression.TryCatch(toObjectExpression, Expression.Catch(typeof(Exception), Expression.Constant(null, typeof(object))));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public static class RegExpExtensions
|
||||
{
|
||||
extension(Group group)
|
||||
{
|
||||
public string? ValueOrNull() => group.Success ? group.Value : null;
|
||||
public string? UnescapeValueOrNull() => group.Success ? CommonFormatters.Unescape(group.ValueSpan, ['\'', '"']) : null;
|
||||
public string UnescapeValue() => group.Success ? CommonFormatters.Unescape(group.ValueSpan, ['\'', '"']) : string.Empty;
|
||||
public ReadOnlySpan<char> ValueSpanOrNull() => group.Success ? group.ValueSpan : null;
|
||||
}
|
||||
|
||||
extension(Match match)
|
||||
{
|
||||
public Group Resolve(string? groupName = null)
|
||||
{
|
||||
if (groupName is not null && match.Groups.TryGetValue(groupName, out var group))
|
||||
return group;
|
||||
return match.Groups.Count > 1 ? match.Groups[1] : match.Groups[0];
|
||||
}
|
||||
|
||||
public string? ResolveValue(string? groupName = null) => match.Resolve(groupName).ValueOrNull();
|
||||
|
||||
public bool TryParseInt(string? groupName, out int value)
|
||||
{
|
||||
var span = match.Resolve(groupName).ValueSpanOrNull();
|
||||
|
||||
return int.TryParse(span, out value);
|
||||
}
|
||||
|
||||
public string UnescapeValue(string? groupName = null) => match.Resolve(groupName).UnescapeValue();
|
||||
public string? UnescapeValueOrNull(string? groupName = null) => match.Resolve(groupName).UnescapeValueOrNull();
|
||||
}
|
||||
|
||||
extension(Regex regex)
|
||||
{
|
||||
public bool TryMatch(string? input, [NotNullWhen(true)] out Match? match)
|
||||
{
|
||||
if (input is null)
|
||||
{
|
||||
match = null;
|
||||
return false;
|
||||
}
|
||||
var m = regex.Match(input);
|
||||
match = m.Success ? m : null;
|
||||
return m.Success;
|
||||
}
|
||||
|
||||
public string ReplaceWithGaps(string input, MatchEvaluator matchEvaluator, Func<string, string> gapEvaluator)
|
||||
{
|
||||
var sb = new StringBuilder(input.Length);
|
||||
var pos = 0;
|
||||
|
||||
foreach (Match m in regex.Matches(input))
|
||||
{
|
||||
// part before match
|
||||
if (m.Index > pos)
|
||||
sb.Append(gapEvaluator(input[pos..m.Index]));
|
||||
|
||||
// the match itself
|
||||
sb.Append(matchEvaluator(m));
|
||||
|
||||
pos = m.Index + m.Length;
|
||||
}
|
||||
|
||||
// part after last match
|
||||
if (pos < input.Length)
|
||||
sb.Append(gapEvaluator(input[pos..]));
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,19 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
internal enum OutputType
|
||||
{
|
||||
String,
|
||||
Object
|
||||
}
|
||||
|
||||
internal interface IPropertyTag
|
||||
{
|
||||
/// <summary>The tag that will be matched in a tag string</summary>
|
||||
@@ -21,37 +29,33 @@ internal interface IPropertyTag
|
||||
/// Determine if the template string starts with <see cref="TemplateTag"/>, and if it does parse the tag to an <see cref="Expression"/>
|
||||
/// </summary>
|
||||
/// <param name="templateString">Template string</param>
|
||||
/// <param name="outputType">Whether to return a string or object expression</param>
|
||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
|
||||
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
|
||||
bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
|
||||
bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
|
||||
}
|
||||
|
||||
internal abstract class TagBase : IPropertyTag
|
||||
internal abstract class TagBase(ITemplateTag templateTag, Expression propertyExpression) : IPropertyTag
|
||||
{
|
||||
public ITemplateTag TemplateTag { get; }
|
||||
public ITemplateTag TemplateTag { get; } = templateTag;
|
||||
public abstract Regex NameMatcher { get; }
|
||||
public Type ReturnType => ValueExpression.Type;
|
||||
protected Expression ValueExpression { get; }
|
||||
|
||||
protected TagBase(ITemplateTag templateTag, Expression propertyExpression)
|
||||
{
|
||||
TemplateTag = templateTag;
|
||||
ValueExpression = propertyExpression;
|
||||
}
|
||||
protected Expression ValueExpression { get; } = propertyExpression;
|
||||
|
||||
/// <summary>Create an <see cref="Expression"/> that returns the property's value.</summary>
|
||||
/// <param name="exactName">The exact string that was matched to <see cref="ITemplateTag"/></param>
|
||||
/// <param name="extraData">Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options</param>
|
||||
protected abstract Expression GetTagExpression(string exactName, string[] extraData);
|
||||
/// <param name="matchData">Optional extra data parsed from the tag, such as a format string in the match the square brackets, logical negation, and conditional options</param>
|
||||
/// <param name="outputType">Whether to return a string or object expression</param>
|
||||
protected abstract Expression GetTagExpression(string exactName, Dictionary<string, Group> matchData, OutputType outputType);
|
||||
|
||||
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
|
||||
public bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
|
||||
{
|
||||
var match = NameMatcher.Match(templateString);
|
||||
if (match.Success)
|
||||
{
|
||||
exactName = match.Value;
|
||||
propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).Select(v => v.Value.Trim()).ToArray());
|
||||
propertyValue = GetTagExpression(exactName, match.Groups.Values.Skip(1).ToDictionary(v => v.Name, v => v), outputType);
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -60,6 +64,11 @@ internal abstract class TagBase : IPropertyTag
|
||||
return false;
|
||||
}
|
||||
|
||||
protected string TagNameForRegex()
|
||||
{
|
||||
return TemplateTag.TagName.Replace(" ", @"\s*").Replace("#", @"\#");
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"[Name = {TemplateTag.TagName}, Type = {ReturnType.Name}]";
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
@@ -16,28 +17,33 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
|
||||
|
||||
/// <summary>The <see cref="ParameterExpression"/> of the <see cref="TagCollection"/>'s TClass type.</summary>
|
||||
internal ParameterExpression Parameter { get; }
|
||||
protected RegexOptions Options { get; } = RegexOptions.Compiled;
|
||||
internal List<IPropertyTag> PropertyTags { get; } = new();
|
||||
|
||||
protected TagCollection(Type classType, bool caseSensative = true)
|
||||
internal static readonly ParameterExpression CultureParameter = Expression.Parameter(typeof(CultureInfo), "culture");
|
||||
protected RegexOptions Options { get; } = RegexOptions.Compiled;
|
||||
private List<IPropertyTag> PropertyTags { get; } = [];
|
||||
|
||||
protected TagCollection(Type classType, bool caseSensitive = true)
|
||||
{
|
||||
Parameter = Expression.Parameter(classType, classType.Name);
|
||||
Options |= caseSensative ? RegexOptions.None : RegexOptions.IgnoreCase;
|
||||
Options |= caseSensitive ? RegexOptions.None : RegexOptions.IgnoreCase;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Determine if the template string starts with any of the <see cref="TemplateTags"/>s' <see cref="ITemplateTag"/> signatures,
|
||||
/// Determine if the template string starts with any of the <see cref="PropertyTags"/>s' <see cref="ITemplateTag"/> signatures,
|
||||
/// and if it does parse the tag to an <see cref="Expression"/>
|
||||
/// </summary>
|
||||
/// <param name="templateString">Template string</param>
|
||||
/// <param name="outputType">Whether to return a string or object expression</param>
|
||||
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
|
||||
/// <param name="propertyTag"></param>
|
||||
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
|
||||
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
|
||||
internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue)
|
||||
internal bool StartsWith(string templateString, OutputType outputType, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag,
|
||||
[NotNullWhen(true)] out Expression? propertyValue)
|
||||
{
|
||||
foreach (var p in PropertyTags)
|
||||
{
|
||||
if (p.StartsWith(templateString, out exactName, out propertyValue))
|
||||
if (p.StartsWith(templateString, outputType, out exactName, out propertyValue))
|
||||
{
|
||||
propertyTag = p;
|
||||
return true;
|
||||
@@ -73,7 +79,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
|
||||
|
||||
private protected void AddPropertyTag(IPropertyTag propertyTag)
|
||||
{
|
||||
if (!PropertyTags.Any(c => c.TemplateTag.TagName == propertyTag.TemplateTag.TagName))
|
||||
if (PropertyTags.All(c => c.TemplateTag.TagName != propertyTag.TemplateTag.TagName))
|
||||
PropertyTags.Add(propertyTag);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,14 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
public string TagName { get; }
|
||||
|
||||
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
|
||||
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
|
||||
/// a registered property, otherwise <c>null</c> for string literals. </summary>
|
||||
public ITemplateTag? TemplateTag { get; }
|
||||
|
||||
/// <summary>The evaluated string.</summary>
|
||||
public string Value { get; }
|
||||
|
||||
private TemplatePart? previous;
|
||||
private TemplatePart? next;
|
||||
private TemplatePart? _previous;
|
||||
private TemplatePart? _next;
|
||||
private TemplatePart(string name, string value)
|
||||
{
|
||||
TagName = name;
|
||||
@@ -62,18 +62,18 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
|
||||
if (type.GetConstructor(
|
||||
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
new Type[] { typeof(string), typeof(string) }) is not ConstructorInfo c1)
|
||||
[typeof(string), typeof(string)]) is not { } c1)
|
||||
throw new MissingMethodException(nameof(TemplatePart));
|
||||
|
||||
if (type.GetConstructor(
|
||||
BindingFlags.NonPublic | BindingFlags.Instance,
|
||||
new Type[] { typeof(ITemplateTag), typeof(string) }) is not ConstructorInfo c2)
|
||||
[typeof(ITemplateTag), typeof(string)]) is not { } c2)
|
||||
throw new MissingMethodException(nameof(TemplatePart));
|
||||
|
||||
if (type.GetMethod(
|
||||
nameof(Concatenate),
|
||||
BindingFlags.NonPublic | BindingFlags.Static,
|
||||
new Type[] { typeof(TemplatePart), typeof(TemplatePart) }) is not MethodInfo m1)
|
||||
[typeof(TemplatePart), typeof(TemplatePart)]) is not { } m1)
|
||||
throw new MissingMethodException(nameof(Concatenate));
|
||||
|
||||
constructorInfo = c1;
|
||||
@@ -89,7 +89,7 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
{
|
||||
if (firstPart.TemplateTag is not null || firstPart.TagName is not "Blank")
|
||||
yield return firstPart;
|
||||
firstPart = firstPart.next;
|
||||
firstPart = firstPart._next;
|
||||
}
|
||||
while (firstPart is not null);
|
||||
}
|
||||
@@ -101,8 +101,8 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
get
|
||||
{
|
||||
var part = this;
|
||||
while (part.previous is not null)
|
||||
part = part.previous;
|
||||
while (part._previous is not null)
|
||||
part = part._previous;
|
||||
return part;
|
||||
}
|
||||
}
|
||||
@@ -112,8 +112,8 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
get
|
||||
{
|
||||
var part = this;
|
||||
while (part.next is not null)
|
||||
part = part.next;
|
||||
while (part._next is not null)
|
||||
part = part._next;
|
||||
return part;
|
||||
}
|
||||
}
|
||||
@@ -121,8 +121,8 @@ public class TemplatePart : IEnumerable<TemplatePart>
|
||||
private static TemplatePart Concatenate(TemplatePart left, TemplatePart right)
|
||||
{
|
||||
var last = left.LastPart;
|
||||
last.next = right;
|
||||
right.previous = last;
|
||||
last._next = right;
|
||||
right._previous = last;
|
||||
return left.FirstPart;
|
||||
}
|
||||
}
|
||||
@@ -241,7 +241,7 @@ public class ReplacementCharacters
|
||||
|
||||
if (CharIsPathInvalid(c)
|
||||
|| invalidSlashes.Contains(c)
|
||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
|
||||
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that the user wants. */)
|
||||
{
|
||||
char preceding = i > 0 ? fileName[i - 1] : default;
|
||||
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
|
||||
@@ -265,7 +265,7 @@ public class ReplacementCharacters
|
||||
if (
|
||||
(
|
||||
CharIsPathInvalid(c)
|
||||
|| ( // Replace any other legal characters that they user wants.
|
||||
|| ( // Replace any other legal characters that the user wants.
|
||||
c != Path.DirectorySeparatorChar
|
||||
&& c != Path.AltDirectorySeparatorChar
|
||||
&& Replacements.Any(r => r.CharacterToReplace == c)
|
||||
|
||||
@@ -3,14 +3,16 @@
|
||||
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"
|
||||
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
|
||||
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
|
||||
x:Class="HangoverAvalonia.Controls.CheckedListBox">
|
||||
|
||||
<ScrollViewer
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
|
||||
<ItemsControl ItemsSource="{Binding $parent[controls:CheckedListBox].Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DataTemplate x:DataType="vms:CheckBoxViewModel">
|
||||
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -70,11 +70,11 @@
|
||||
<TrimmableAssembly Include="Avalonia.Themes.Default" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.11" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.11" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
|
||||
@@ -18,5 +18,5 @@ internal class Program
|
||||
=> AppBuilder.Configure<App>()
|
||||
.UsePlatformDetect()
|
||||
.LogToTrace()
|
||||
.UseReactiveUI();
|
||||
.UseReactiveUI(_ => { });
|
||||
}
|
||||
@@ -4,6 +4,8 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
|
||||
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
|
||||
x:DataType="vms:MainVM"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
|
||||
Width="800" Height="500"
|
||||
x:Class="HangoverAvalonia.Views.MainWindow"
|
||||
|
||||
@@ -89,7 +89,7 @@
|
||||
</Style>
|
||||
|
||||
<Style Selector="dialogs|DialogWindow">
|
||||
<Setter Property="SystemDecorations" Value="Full"/>
|
||||
<Setter Property="WindowDecorations" Value="Full"/>
|
||||
<Setter Property="Icon" Value="/Assets/libation.ico"/>
|
||||
<Setter Property="Template">
|
||||
<ControlTemplate>
|
||||
|
||||
@@ -47,9 +47,6 @@ public class App : Application
|
||||
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
|
||||
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
|
||||
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
|
||||
DisableAvaloniaDataAnnotationValidation();
|
||||
if (LibraryTask is null)
|
||||
{
|
||||
RunSetupIfNeededAsync(desktop, Configuration.Instance);
|
||||
@@ -76,6 +73,7 @@ public class App : Application
|
||||
{
|
||||
// setup succeeded or wasn't needed and LibationFiles are valid
|
||||
RunMigrations(config);
|
||||
StartupAssemblyBootstrap.PrepareForBackgroundDataAccess();
|
||||
LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
|
||||
ShowMainWindow(desktop);
|
||||
}
|
||||
@@ -114,19 +112,6 @@ public class App : Application
|
||||
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
|
||||
}
|
||||
|
||||
private void DisableAvaloniaDataAnnotationValidation()
|
||||
{
|
||||
// Get an array of plugins to remove
|
||||
DataAnnotationsValidationPlugin[] dataValidationPluginsToRemove =
|
||||
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
|
||||
|
||||
// remove each entry found
|
||||
foreach (DataAnnotationsValidationPlugin? plugin in dataValidationPluginsToRemove)
|
||||
{
|
||||
BindingPlugins.DataValidators.Remove(plugin);
|
||||
}
|
||||
}
|
||||
|
||||
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
|
||||
@@ -158,8 +143,22 @@ public class App : Application
|
||||
{
|
||||
if (LibraryTask is not null && MainWindow is not null)
|
||||
{
|
||||
List<DataLayer.LibraryBook> library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
try
|
||||
{
|
||||
List<DataLayer.LibraryBook> library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
}
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex))
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
StartupAssemblyBootstrap.GetLibraryLoadFailureMessage(),
|
||||
"Library load failed",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([]));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,7 +24,7 @@ internal static class AvaloniaUtils
|
||||
: Task.FromResult(DialogResult.None);
|
||||
|
||||
public static Window GetParentWindow(this Control control)
|
||||
=> control.GetVisualRoot() as Window ?? App.MainWindow
|
||||
=> TopLevel.GetTopLevel(control) as Window ?? App.MainWindow
|
||||
?? throw new InvalidOperationException("Cannot find parent window.");
|
||||
|
||||
|
||||
|
||||
@@ -3,14 +3,15 @@
|
||||
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"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
x:Class="LibationAvalonia.Controls.CheckedListBox">
|
||||
|
||||
<ScrollViewer
|
||||
HorizontalScrollBarVisibility="Disabled"
|
||||
VerticalScrollBarVisibility="Auto">
|
||||
<ItemsControl ItemsSource="{Binding $parent[1].Items}">
|
||||
<ItemsControl ItemsSource="{Binding $parent[controls:CheckedListBox].Items}">
|
||||
<ItemsControl.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<DataTemplate x:DataType="controls:CheckBoxViewModel">
|
||||
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
|
||||
</DataTemplate>
|
||||
</ItemsControl.ItemTemplate>
|
||||
|
||||
@@ -4,11 +4,22 @@ using Avalonia.Input;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LibationAvalonia.Controls;
|
||||
|
||||
public class DataGridCellContextMenu<TContext> where TContext : class
|
||||
{
|
||||
private static readonly PropertyInfo? DataGridCellOwningColumnProperty =
|
||||
typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
|
||||
|
||||
private static DataGridColumn? GetDataGridColumn(DataGridCell cell)
|
||||
{
|
||||
if (cell.Tag is DataGridColumn columnFromTag)
|
||||
return columnFromTag;
|
||||
return DataGridCellOwningColumnProperty?.GetValue(cell) as DataGridColumn;
|
||||
}
|
||||
|
||||
public static DataGridCellContextMenu<TContext>? Create(ContextMenu? contextMenu)
|
||||
{
|
||||
DataGrid? grid = null;
|
||||
@@ -22,7 +33,11 @@ public class DataGridCellContextMenu<TContext> where TContext : class
|
||||
parent = parent.Parent;
|
||||
}
|
||||
|
||||
if (grid is null || cell is null || cell.Tag is not DataGridColumn column || contextMenu!.DataContext is not TContext clickedEntry)
|
||||
if (grid is null || cell is null || contextMenu!.DataContext is not TContext clickedEntry)
|
||||
return null;
|
||||
|
||||
var column = GetDataGridColumn(cell);
|
||||
if (column is null)
|
||||
return null;
|
||||
|
||||
var allSelected = grid.SelectedItems.OfType<TContext>().ToArray();
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace LibationAvalonia.Controls;
|
||||
|
||||
public class DataGridMyRatingColumn : DataGridBoundColumn
|
||||
{
|
||||
[AssignBinding] public IBinding? BackgroundBinding { get; set; }
|
||||
[AssignBinding] public IBinding? OpacityBinding { get; set; }
|
||||
[AssignBinding] public BindingBase? BackgroundBinding { get; set; }
|
||||
[AssignBinding] public BindingBase? OpacityBinding { get; set; }
|
||||
private static Rating DefaultRating => new Rating(0, 0, 0);
|
||||
public DataGridMyRatingColumn()
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
x:DataType="controls:DirectoryOrCustomSelectControl"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
x:Class="LibationAvalonia.Controls.DirectoryOrCustomSelectControl">
|
||||
|
||||
@@ -39,7 +40,7 @@
|
||||
Margin="0,0,0,8"
|
||||
Name="tboxKnownDirPath"
|
||||
IsEnabled="{Binding #rbKnown.IsChecked}"
|
||||
Text="{Binding #cmbKnownDirs.SelectedItem.Directory}" />
|
||||
Text="{Binding #cmbKnownDirs.((controls:KnownDirectoryItem)SelectedItem).Directory}"/>
|
||||
|
||||
|
||||
<RadioButton
|
||||
@@ -53,7 +54,7 @@
|
||||
Name="tboxCustomDirPath"
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{Binding $parent[1].Directory, Mode=OneWayToSource}"
|
||||
Text="{Binding $parent[controls:DirectoryOrCustomSelectControl].Directory, Mode=OneWayToSource}"
|
||||
IsEnabled="{Binding #rbCustom.IsChecked}"/>
|
||||
|
||||
<Button
|
||||
|
||||
@@ -3,6 +3,7 @@ using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
@@ -124,7 +125,8 @@ public partial class DirectoryOrCustomSelectControl : UserControl
|
||||
|
||||
public async void Browse_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (VisualRoot is not Window window)
|
||||
var window = this.GetParentWindow();
|
||||
if (window is null)
|
||||
return;
|
||||
|
||||
var options = new FolderPickerOpenOptions
|
||||
@@ -132,49 +134,67 @@ public partial class DirectoryOrCustomSelectControl : UserControl
|
||||
AllowMultiple = false
|
||||
};
|
||||
|
||||
var selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options);
|
||||
var start = FolderPickerInitialPath.GetExistingDirectoryOrNull(tboxCustomDirPath.Text);
|
||||
if (start is not null)
|
||||
{
|
||||
var loc = await window.StorageProvider.TryGetFolderFromPathAsync(start);
|
||||
if (loc is not null)
|
||||
options.SuggestedStartLocation = loc;
|
||||
}
|
||||
|
||||
IReadOnlyList<IStorageFolder> selectedFolders;
|
||||
try
|
||||
{
|
||||
selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options);
|
||||
}
|
||||
catch
|
||||
{
|
||||
options.SuggestedStartLocation = null;
|
||||
selectedFolders = await window.StorageProvider.OpenFolderPickerAsync(options);
|
||||
}
|
||||
|
||||
Directory = selectedFolders.SingleOrDefault()?.TryGetLocalPath() ?? Directory;
|
||||
}
|
||||
|
||||
private class KnownDirectoryItem : ReactiveObject
|
||||
{
|
||||
public Configuration.KnownDirectories KnownDirectory { get; set; }
|
||||
public string? Directory { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
|
||||
public string? Name { get; }
|
||||
public string? SubDirectory
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
if (Configuration.GetKnownDirectoryPath(KnownDirectory) is string dir)
|
||||
{
|
||||
Directory = Path.Combine(dir, field ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public KnownDirectoryItem(Configuration.KnownDirectories known, string? subDir)
|
||||
{
|
||||
Name = known.GetDescription();
|
||||
KnownDirectory = known;
|
||||
SubDirectory = subDir;
|
||||
}
|
||||
|
||||
public bool IsSamePathAs(string? otherPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(otherPath) || string.IsNullOrWhiteSpace(Directory))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var p1 = Path.GetFullPath(Directory);
|
||||
var p2 = Path.GetFullPath(otherPath);
|
||||
return p1.Equals(p2, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public override string? ToString() => Name?.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
public class KnownDirectoryItem : ReactiveObject
|
||||
{
|
||||
public Configuration.KnownDirectories KnownDirectory { get; set; }
|
||||
public string? Directory { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
|
||||
public string? Name { get; }
|
||||
public string? SubDirectory
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
field = value;
|
||||
if (Configuration.GetKnownDirectoryPath(KnownDirectory) is string dir)
|
||||
{
|
||||
Directory = Path.Combine(dir, field ?? "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public KnownDirectoryItem(Configuration.KnownDirectories known, string? subDir)
|
||||
{
|
||||
Name = known.GetDescription();
|
||||
KnownDirectory = known;
|
||||
SubDirectory = subDir;
|
||||
}
|
||||
|
||||
public bool IsSamePathAs(string? otherPath)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(otherPath) || string.IsNullOrWhiteSpace(Directory))
|
||||
return false;
|
||||
|
||||
try
|
||||
{
|
||||
var p1 = Path.GetFullPath(Directory);
|
||||
var p2 = Path.GetFullPath(otherPath);
|
||||
return p1.Equals(p2, System.StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch { return false; }
|
||||
}
|
||||
|
||||
public override string? ToString() => Name?.ToString();
|
||||
}
|
||||
@@ -18,13 +18,13 @@
|
||||
<Setter Property="Height" Value="NaN"/>
|
||||
</Style>
|
||||
</StackPanel.Styles>
|
||||
<!-- MinHeight="{Binding #displayPathTbox.MinHeight}" -->
|
||||
<controls:WheelComboBox
|
||||
HorizontalContentAlignment = "Stretch"
|
||||
HorizontalAlignment = "Stretch"
|
||||
Name="combo"
|
||||
MinHeight="{Binding #displayPathTbox.MinHeight}"
|
||||
SelectedItem="{Binding $parent[1].SelectedDirectory, Mode=TwoWay}"
|
||||
ItemsSource="{Binding $parent[1].KnownDirectories}">
|
||||
SelectedItem="{Binding $parent[controls:DirectorySelectControl].SelectedDirectory, Mode=TwoWay}"
|
||||
ItemsSource="{Binding $parent[controls:DirectorySelectControl].KnownDirectories}">
|
||||
<ComboBox.ItemTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Text="{Binding Converter={StaticResource KnownDirectoryConverter}}" />
|
||||
@@ -36,7 +36,7 @@
|
||||
<MultiBinding Converter="{StaticResource KnownDirectoryPath}">
|
||||
<MultiBinding.Bindings>
|
||||
<Binding Path="#combo.SelectedItem"/>
|
||||
<Binding Path="$parent[1].SubDirectory"/>
|
||||
<Binding Path="$parent[controls:DirectorySelectControl].SubDirectory"/>
|
||||
</MultiBinding.Bindings>
|
||||
</MultiBinding>
|
||||
</TextBox.Text>
|
||||
|
||||
@@ -30,29 +30,26 @@ public partial class Audio : UserControl
|
||||
|
||||
if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens?.DeviceType == AudibleApi.Resources.DeviceType))
|
||||
{
|
||||
if (VisualRoot is Window parent)
|
||||
{
|
||||
var choice = await MessageBox.Show(parent,
|
||||
"In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" +
|
||||
"Do you want Libation to clear your current account settings and prompt you to login before the next download?",
|
||||
"Widevine Content Unavailable",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
var choice = await MessageBox.Show(this.GetParentWindow(),
|
||||
"In order to enable widevine content, Libation will need to log into your accounts again.\r\n\r\n" +
|
||||
"Do you want Libation to clear your current account settings and prompt you to login before the next download?",
|
||||
"Widevine Content Unavailable",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button2);
|
||||
|
||||
if (choice == DialogResult.Yes)
|
||||
if (choice == DialogResult.Yes)
|
||||
{
|
||||
foreach (var account in accounts.AccountsSettings.Accounts.ToArray())
|
||||
{
|
||||
foreach (var account in accounts.AccountsSettings.Accounts.ToArray())
|
||||
if (account.Locale is not null && account.IdentityTokens?.DeviceType != AudibleApi.Resources.DeviceType)
|
||||
{
|
||||
if (account.Locale is not null && account.IdentityTokens?.DeviceType != AudibleApi.Resources.DeviceType)
|
||||
{
|
||||
accounts.AccountsSettings.Delete(account);
|
||||
var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name);
|
||||
acc.AccountName = account.AccountName;
|
||||
}
|
||||
accounts.AccountsSettings.Delete(account);
|
||||
var acc = accounts.AccountsSettings.Upsert(account.AccountId, account.Locale.Name);
|
||||
acc.AccountName = account.AccountName;
|
||||
}
|
||||
return;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
_viewModel?.UseWidevine = false;
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
<controls:GroupBox
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
Label="{CompiledBinding BadBookGroupboxText}">
|
||||
Label="{Binding BadBookGroupboxText}">
|
||||
|
||||
<Grid
|
||||
ColumnDefinitions="*,*"
|
||||
@@ -29,36 +29,36 @@
|
||||
<RadioButton
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
IsChecked="{CompiledBinding BadBookAsk, Mode=TwoWay}">
|
||||
IsChecked="{Binding BadBookAsk, Mode=TwoWay}">
|
||||
|
||||
<TextBlock Text="{CompiledBinding BadBookAskText}" />
|
||||
<TextBlock Text="{Binding BadBookAskText}" />
|
||||
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton
|
||||
Grid.Column="1"
|
||||
Grid.Row="0"
|
||||
IsChecked="{CompiledBinding BadBookAbort, Mode=TwoWay}">
|
||||
IsChecked="{Binding BadBookAbort, Mode=TwoWay}">
|
||||
|
||||
<TextBlock Text="{CompiledBinding BadBookAbortText}" />
|
||||
<TextBlock Text="{Binding BadBookAbortText}" />
|
||||
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
IsChecked="{CompiledBinding BadBookRetry, Mode=TwoWay}">
|
||||
IsChecked="{Binding BadBookRetry, Mode=TwoWay}">
|
||||
|
||||
<TextBlock Text="{CompiledBinding BadBookRetryText}" />
|
||||
<TextBlock Text="{Binding BadBookRetryText}" />
|
||||
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton
|
||||
Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
IsChecked="{CompiledBinding BadBookIgnore, Mode=TwoWay}">
|
||||
IsChecked="{Binding BadBookIgnore, Mode=TwoWay}">
|
||||
|
||||
<TextBlock Text="{CompiledBinding BadBookIgnoreText}" />
|
||||
<TextBlock Text="{Binding BadBookIgnoreText}" />
|
||||
|
||||
</RadioButton>
|
||||
</Grid>
|
||||
@@ -90,12 +90,12 @@
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Margin="0,5,0,0"
|
||||
Text="{CompiledBinding FolderTemplateText}" />
|
||||
Text="{Binding FolderTemplateText}" />
|
||||
|
||||
<TextBox
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Text="{CompiledBinding FolderTemplate}" />
|
||||
Text="{Binding FolderTemplate}" />
|
||||
|
||||
<Button
|
||||
Grid.Row="1"
|
||||
@@ -106,12 +106,12 @@
|
||||
<TextBlock
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
Text="{CompiledBinding FileTemplateText}" />
|
||||
Text="{Binding FileTemplateText}" />
|
||||
|
||||
<TextBox
|
||||
Grid.Row="3"
|
||||
Grid.Column="0"
|
||||
Text="{CompiledBinding FileTemplate}" />
|
||||
Text="{Binding FileTemplate}" />
|
||||
|
||||
<Button
|
||||
Grid.Row="3"
|
||||
@@ -122,12 +122,12 @@
|
||||
<TextBlock
|
||||
Grid.Row="4"
|
||||
Grid.Column="0"
|
||||
Text="{CompiledBinding ChapterFileTemplateText}" />
|
||||
Text="{Binding ChapterFileTemplateText}" />
|
||||
|
||||
<TextBox
|
||||
Grid.Row="5"
|
||||
Grid.Column="0"
|
||||
Text="{CompiledBinding ChapterFileTemplate}" />
|
||||
Text="{Binding ChapterFileTemplate}" />
|
||||
|
||||
<Button
|
||||
Grid.Row="5"
|
||||
@@ -140,7 +140,7 @@
|
||||
Grid.Column="0"
|
||||
Height="30"
|
||||
Margin="0"
|
||||
Content="{CompiledBinding EditCharReplacementText}"
|
||||
Content="{Binding EditCharReplacementText}"
|
||||
Click="EditCharReplacementButton_Click" />
|
||||
|
||||
</Grid>
|
||||
@@ -156,11 +156,11 @@
|
||||
<TextBlock
|
||||
Margin="0,0,0,10"
|
||||
TextWrapping="Wrap"
|
||||
Text="{CompiledBinding InProgressDescriptionText}" />
|
||||
Text="{Binding InProgressDescriptionText}" />
|
||||
|
||||
<controls:DirectoryOrCustomSelectControl
|
||||
Directory="{CompiledBinding InProgressDirectory, Mode=TwoWay}"
|
||||
KnownDirectories="{CompiledBinding KnownDirectories}" />
|
||||
Directory="{Binding InProgressDirectory, Mode=TwoWay}"
|
||||
KnownDirectories="{Binding KnownDirectories}" />
|
||||
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
@@ -172,23 +172,23 @@
|
||||
<CheckBox
|
||||
Margin="5"
|
||||
VerticalAlignment="Top"
|
||||
IsVisible="{CompiledBinding !Config.IsLinux}"
|
||||
IsChecked="{CompiledBinding UseCoverAsFolderIcon, Mode=TwoWay}">
|
||||
IsVisible="{Binding !Config.IsLinux}"
|
||||
IsChecked="{Binding UseCoverAsFolderIcon, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="{CompiledBinding UseCoverAsFolderIconText}" />
|
||||
Text="{Binding UseCoverAsFolderIconText}" />
|
||||
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox
|
||||
Margin="5"
|
||||
VerticalAlignment="Top"
|
||||
IsChecked="{CompiledBinding SaveMetadataToFile, Mode=TwoWay}">
|
||||
IsChecked="{Binding SaveMetadataToFile, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="{CompiledBinding SaveMetadataToFileText}" />
|
||||
Text="{Binding SaveMetadataToFileText}" />
|
||||
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
<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"
|
||||
@@ -18,28 +18,29 @@
|
||||
</Style>
|
||||
</StackPanel.Styles>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding AutoScan, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding AutoScanText}" />
|
||||
<CheckBox IsChecked="{Binding AutoScan, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding AutoScanText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding ShowImportedStats, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding ShowImportedStatsText}" />
|
||||
<CheckBox IsChecked="{Binding ShowImportedStats, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding ShowImportedStatsText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding ImportEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding ImportEpisodesText}" />
|
||||
<CheckBox IsChecked="{Binding ImportEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding ImportEpisodesText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding ImportPlusTitles, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding ImportPlusTitlesText}" />
|
||||
<CheckBox IsChecked="{Binding ImportPlusTitles, Mode=TwoWay}"
|
||||
ToolTip.Tip="{Binding ImportPlusTitlesTip}">
|
||||
<TextBlock Text="{Binding ImportPlusTitlesText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding DownloadEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding DownloadEpisodesText}" />
|
||||
<CheckBox IsChecked="{Binding DownloadEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding DownloadEpisodesText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding AutoDownloadEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding AutoDownloadEpisodesText}" />
|
||||
<CheckBox IsChecked="{Binding AutoDownloadEpisodes, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding AutoDownloadEpisodesText}" />
|
||||
</CheckBox>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -17,19 +17,19 @@
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
Margin="5"
|
||||
Text="{CompiledBinding BooksText}" />
|
||||
Text="{Binding BooksText}" />
|
||||
|
||||
<controls:DirectoryOrCustomSelectControl Margin="0,10,0,10"
|
||||
SubDirectory="Books"
|
||||
Directory="{CompiledBinding BooksDirectory, Mode=TwoWay}"
|
||||
KnownDirectories="{CompiledBinding KnownDirectories}" />
|
||||
Directory="{Binding BooksDirectory, Mode=TwoWay}"
|
||||
KnownDirectories="{Binding KnownDirectories}" />
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding SavePodcastsToParentFolder, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding SavePodcastsToParentFolderText}" />
|
||||
<CheckBox IsChecked="{Binding SavePodcastsToParentFolder, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding SavePodcastsToParentFolderText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding OverwriteExisting, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding OverwriteExistingText}" />
|
||||
<CheckBox IsChecked="{Binding OverwriteExisting, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding OverwriteExistingText}" />
|
||||
</CheckBox>
|
||||
|
||||
<Grid
|
||||
@@ -39,21 +39,21 @@
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"
|
||||
Text="{CompiledBinding CreationTimeText}" />
|
||||
Text="{Binding CreationTimeText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Height="25"
|
||||
Grid.Column="1"
|
||||
Margin="0,5"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{CompiledBinding CreationTime, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding DateTimeSources}" />
|
||||
SelectedItem="{Binding CreationTime, Mode=TwoWay}"
|
||||
ItemsSource="{Binding DateTimeSources}" />
|
||||
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Grid.Row="1"
|
||||
Margin="0,0,10,0"
|
||||
Text="{CompiledBinding LastWriteTimeText}" />
|
||||
Text="{Binding LastWriteTimeText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Height="25"
|
||||
@@ -61,8 +61,8 @@
|
||||
Grid.Column="1"
|
||||
Margin="0,5"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{CompiledBinding LastWriteTime, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding DateTimeSources}" />
|
||||
SelectedItem="{Binding LastWriteTime, Mode=TwoWay}"
|
||||
ItemsSource="{Binding DateTimeSources}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
@@ -72,16 +72,16 @@
|
||||
<CheckBox
|
||||
Grid.Row="1"
|
||||
Margin="10,5"
|
||||
IsEnabled="{CompiledBinding !UseWebViewSettingDisabled}"
|
||||
IsChecked="{CompiledBinding UseWebView, Mode=TwoWay}">
|
||||
IsEnabled="{Binding !UseWebViewSettingDisabled}"
|
||||
IsChecked="{Binding UseWebView, Mode=TwoWay}">
|
||||
<StackPanel>
|
||||
<TextBlock Text="{CompiledBinding UseWebViewText}" />
|
||||
<TextBlock Text="{Binding UseWebViewText}" />
|
||||
<TextBlock
|
||||
IsVisible="{CompiledBinding UseWebViewSettingDisabled}"
|
||||
IsVisible="{Binding UseWebViewSettingDisabled}"
|
||||
FontStyle="Italic"
|
||||
Opacity="0.8"
|
||||
Margin="0,2,0,0"
|
||||
Text="{CompiledBinding UseWebViewSnapMessage}" />
|
||||
Text="{Binding UseWebViewSnapMessage}" />
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
|
||||
@@ -98,15 +98,15 @@
|
||||
Width="120"
|
||||
Height="25"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{CompiledBinding LoggingLevel, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding LoggingLevels}" />
|
||||
SelectedItem="{Binding LoggingLevel, Mode=TwoWay}"
|
||||
ItemsSource="{Binding LoggingLevels}" />
|
||||
|
||||
<Button
|
||||
Margin="50,0,0,0"
|
||||
Padding="20,0"
|
||||
VerticalAlignment="Stretch"
|
||||
Content="Open Log Folder"
|
||||
Command="{CompiledBinding OpenLogFolderButton}" />
|
||||
Command="{Binding OpenLogFolderButton}" />
|
||||
|
||||
</StackPanel>
|
||||
|
||||
@@ -121,12 +121,12 @@
|
||||
<TextBlock
|
||||
Margin="0,0,10,0"
|
||||
VerticalAlignment="Center"
|
||||
Text="{CompiledBinding GridScaleFactorText}"/>
|
||||
Text="{Binding GridScaleFactorText}"/>
|
||||
|
||||
<Slider
|
||||
Grid.Column="1"
|
||||
Width="200"
|
||||
Value="{CompiledBinding GridScaleFactor, Mode=TwoWay}"
|
||||
Value="{Binding GridScaleFactor, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"
|
||||
Minimum="-100"
|
||||
Maximum="100"
|
||||
@@ -139,13 +139,13 @@
|
||||
Margin="0,0,10,0"
|
||||
Grid.Row="1"
|
||||
VerticalAlignment="Center"
|
||||
Text="{CompiledBinding GridFontScaleFactorText}"/>
|
||||
Text="{Binding GridFontScaleFactorText}"/>
|
||||
|
||||
<Slider
|
||||
Grid.Column="1"
|
||||
Grid.Row="1"
|
||||
Width="200"
|
||||
Value="{CompiledBinding GridFontScaleFactor, Mode=TwoWay}"
|
||||
Value="{Binding GridFontScaleFactor, Mode=TwoWay}"
|
||||
VerticalAlignment="Center"
|
||||
Minimum="-100"
|
||||
Maximum="100"
|
||||
@@ -161,7 +161,7 @@
|
||||
Padding="20,0"
|
||||
VerticalAlignment="Stretch"
|
||||
Content="Apply Display Settings"
|
||||
Command="{CompiledBinding ApplyDisplaySettings}"/>
|
||||
Command="{Binding ApplyDisplaySettings}"/>
|
||||
</Grid>
|
||||
</controls:GroupBox>
|
||||
|
||||
@@ -181,8 +181,8 @@
|
||||
Name="ThemeComboBox"
|
||||
Grid.Column="1"
|
||||
MinWidth="80"
|
||||
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding Themes}"/>
|
||||
SelectedItem="{Binding ThemeVariant, Mode=TwoWay}"
|
||||
ItemsSource="{Binding Themes}"/>
|
||||
|
||||
<Button
|
||||
Grid.Column="2"
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="650"
|
||||
xmlns:views="clr-namespace:LibationAvalonia.Views"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
x:DataType="controls:ThemePreviewControl"
|
||||
x:Class="LibationAvalonia.Controls.ThemePreviewControl">
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
<controls:GroupBox>
|
||||
|
||||
@@ -53,7 +53,7 @@ public partial class ThemePreviewControl : UserControl
|
||||
{
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Book 2", dateAdded: System.DateTime.Now.AddDays(3)).WithBookStatus(LiberatedStatus.PartialDownload);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absetFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absentFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Book 4", dateAdded: System.DateTime.Now.AddDays(1)).WithBookStatus(LiberatedStatus.Error);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Series", subtitle: "", contentType: ContentType.Parent).AddSeries("Some Series", 0);
|
||||
yield return MockLibraryBook.CreateBook(title: "Some Episode", subtitle: "Episode 1", contentType: ContentType.Episode).AddSeries("Some Series", 1);
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
Width="450" Height="540"
|
||||
x:Class="LibationAvalonia.Dialogs.AboutDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:AboutVM"
|
||||
Title="About Libation">
|
||||
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
|
||||
@@ -4,6 +4,8 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
|
||||
Width="800" Height="450"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:AccountsDialog"
|
||||
x:Class="LibationAvalonia.Dialogs.AccountsDialog"
|
||||
Title="Audible Accounts">
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
@@ -45,8 +47,8 @@
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{Binding !IsDefault}"
|
||||
ToolTip.Tip="Export account authorization to audible-cli"
|
||||
IsEnabled="{Binding CanExport}"
|
||||
ToolTip.Tip="{Binding ExportButtonToolTip}"
|
||||
Click="ExportButton_Clicked" />
|
||||
|
||||
</DataTemplate>
|
||||
|
||||
@@ -28,20 +28,65 @@ public partial class AccountsDialog : DialogWindow
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref field, value);
|
||||
this.RaisePropertyChanged(nameof(IsDefault));
|
||||
RefreshCanExport();
|
||||
}
|
||||
}
|
||||
|
||||
public Locale? SelectedLocale
|
||||
{
|
||||
get => field;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref field, value);
|
||||
RefreshCanExport();
|
||||
}
|
||||
}
|
||||
|
||||
public Locale? SelectedLocale { get; set; }
|
||||
public string? AccountName { get; set; }
|
||||
public bool IsDefault => string.IsNullOrEmpty(AccountId);
|
||||
|
||||
public AccountDto() { }
|
||||
public bool CanExport
|
||||
{
|
||||
get => field;
|
||||
private set => this.RaiseAndSetIfChanged(ref field, value);
|
||||
}
|
||||
|
||||
public string ExportButtonToolTip =>
|
||||
CanExport
|
||||
? "Export account authorization to audible-cli"
|
||||
: "Authenticate this account (e.g. library scan) before exporting to audible-cli.";
|
||||
|
||||
public AccountDto() => RefreshCanExport();
|
||||
|
||||
public AccountDto(Account account)
|
||||
{
|
||||
LibraryScan = account.LibraryScan;
|
||||
AccountId = account.AccountId;
|
||||
SelectedLocale = Locales.Single(l => l.Name == account.Locale?.Name);
|
||||
AccountName = account.AccountName;
|
||||
RefreshCanExportFromAccount(account);
|
||||
}
|
||||
|
||||
private void RefreshCanExportFromAccount(Account account)
|
||||
{
|
||||
CanExport = account.IdentityTokens?.IsValid == true;
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
}
|
||||
|
||||
private void RefreshCanExport()
|
||||
{
|
||||
if (string.IsNullOrEmpty(AccountId) || SelectedLocale is null)
|
||||
{
|
||||
CanExport = false;
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
return;
|
||||
}
|
||||
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a =>
|
||||
a.AccountId == AccountId && a.Locale?.Name == SelectedLocale.Name);
|
||||
CanExport = account?.IdentityTokens?.IsValid == true;
|
||||
this.RaisePropertyChanged(nameof(ExportButtonToolTip));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,21 +166,22 @@ public partial class AccountsDialog : DialogWindow
|
||||
try
|
||||
{
|
||||
var jsonText = File.ReadAllText(selectedFile);
|
||||
var mkbAuth = Mkb79Auth.FromJson(jsonText) ?? throw new Exception("File did not contain valid mkb79/audible-cli account data.");
|
||||
var account = await mkbAuth.ToAccountAsync();
|
||||
var importResult = await Mkb79AuthImporter.ImportFromJsonTextAsync(jsonText);
|
||||
|
||||
// without transaction, accounts persister will write ANY EDIT immediately to file
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
|
||||
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name))
|
||||
if (importResult.Outcome is Mkb79ImportOutcome.InvalidFile)
|
||||
{
|
||||
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale?.Name}", "Cannot Add Duplicate Account");
|
||||
await MessageBox.Show(this, importResult.Message ?? "Invalid import file.", "Error Importing Account", MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
return;
|
||||
}
|
||||
|
||||
persister.AccountsSettings.Add(account);
|
||||
if (importResult.Outcome is Mkb79ImportOutcome.DuplicateAccount && importResult.Account is { } dup)
|
||||
{
|
||||
await MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {dup.AccountId}\r\nCountry: {dup.Locale?.Name}", "Cannot Add Duplicate Account");
|
||||
return;
|
||||
}
|
||||
|
||||
Accounts.Add(new AccountDto(account));
|
||||
if (importResult.Account is { } account)
|
||||
Accounts.Add(new AccountDto(account));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -5,6 +5,8 @@
|
||||
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="450"
|
||||
Width="700" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.BookRecordsDialog"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:BookRecordsDialog"
|
||||
Title="BookRecordsDialog">
|
||||
|
||||
<Grid RowDefinitions="*,Auto">
|
||||
@@ -28,45 +30,52 @@
|
||||
</DataGrid.Styles>
|
||||
|
||||
<DataGrid.Columns>
|
||||
|
||||
<DataGridCheckBoxColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="False"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding IsChecked, Mode=TwoWay}"
|
||||
Header="Checked"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Type}"
|
||||
Header="Type"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Created}"
|
||||
Header="Created"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Start}"
|
||||
Header="Start"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Modified}"
|
||||
Header="Modified"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding End}"
|
||||
Header="End"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Note}"
|
||||
Header="Note"/>
|
||||
<DataGridTextColumn
|
||||
Width="Auto"
|
||||
IsReadOnly="True"
|
||||
x:DataType="dialogs:BookRecordsDialog+BookRecordEntry"
|
||||
Binding="{Binding Title}"
|
||||
Header="Title"/>
|
||||
|
||||
|
||||
@@ -202,7 +202,7 @@ public partial class BookRecordsDialog : DialogWindow
|
||||
|
||||
#region DataGrid Bindings
|
||||
|
||||
private class BookRecordEntry : ViewModels.ViewModelBase
|
||||
public class BookRecordEntry : ViewModels.ViewModelBase
|
||||
{
|
||||
private const string DateFormat = "yyyy-MM-dd HH\\:mm";
|
||||
public IRecord Record { get; }
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="540" d:DesignHeight="140"
|
||||
x:Class="LibationAvalonia.Dialogs.DescriptionDisplayDialog"
|
||||
SystemDecorations="None"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:DescriptionDisplayDialog"
|
||||
WindowDecorations="None"
|
||||
Title="DescriptionDisplay">
|
||||
|
||||
<Window.Styles>
|
||||
|
||||
@@ -46,7 +46,7 @@ public partial class DescriptionDisplayDialog : Window
|
||||
this.Position = new PixelPoint((int)SpawnLocation.X, (int)Math.Min(SpawnLocation.Y, workingHeight - DescriptionTextBox.DesiredSize.Height));
|
||||
}
|
||||
|
||||
private void DescriptionTextBox_LostFocus(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
private void DescriptionTextBox_LostFocus(object sender, Avalonia.Input.FocusChangedEventArgs e)
|
||||
{
|
||||
Close();
|
||||
}
|
||||
|
||||
@@ -21,9 +21,8 @@ public abstract class DialogWindow : Window
|
||||
{
|
||||
SaveAndRestorePosition = saveAndRestorePosition;
|
||||
KeyDown += DialogWindow_KeyDown;
|
||||
Initialized += DialogWindow_Initialized;
|
||||
Opened += DialogWindow_Opened;
|
||||
Loaded += DialogWindow_Loaded;
|
||||
Opened += DialogWindow_Opened;
|
||||
Closing += DialogWindow_Closing;
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
@@ -38,12 +37,6 @@ public abstract class DialogWindow : Window
|
||||
}
|
||||
}
|
||||
|
||||
private void DialogWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (!CanResize)
|
||||
this.HideMinMaxBtns();
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
|
||||
{
|
||||
base.OnApplyTemplate(e);
|
||||
@@ -51,12 +44,14 @@ public abstract class DialogWindow : Window
|
||||
if (MinHeight == MaxHeight && MinWidth == MaxWidth)
|
||||
{
|
||||
CanResize = false;
|
||||
CanMinimize = false;
|
||||
CanMaximize = false;
|
||||
Height = MinHeight;
|
||||
Width = MinWidth;
|
||||
}
|
||||
}
|
||||
|
||||
private void DialogWindow_Initialized(object? sender, EventArgs e)
|
||||
private void DialogWindow_Loaded(object? sender, EventArgs e)
|
||||
{
|
||||
this.WindowStartupLocation = WindowStartupLocation.CenterOwner;
|
||||
if (SaveAndRestorePosition)
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
CanUserSortColumns="False"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="False"
|
||||
ItemsSource="{CompiledBinding Filters}"
|
||||
ItemsSource="{Binding Filters}"
|
||||
GridLinesVisibility="All">
|
||||
<DataGrid.Columns>
|
||||
|
||||
@@ -29,7 +29,7 @@
|
||||
VerticalAlignment="Stretch"
|
||||
HorizontalAlignment="Stretch"
|
||||
HorizontalContentAlignment="Center"
|
||||
IsEnabled="{CompiledBinding !IsDefault}"
|
||||
IsEnabled="{Binding !IsDefault}"
|
||||
Click="DeleteButton_Clicked" />
|
||||
|
||||
</DataTemplate>
|
||||
@@ -39,13 +39,13 @@
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
IsReadOnly="False"
|
||||
Binding="{CompiledBinding Name, Mode=TwoWay}"
|
||||
Binding="{Binding Name, Mode=TwoWay}"
|
||||
Header="Name"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="*"
|
||||
IsReadOnly="False"
|
||||
Binding="{CompiledBinding FilterString, Mode=TwoWay}"
|
||||
Binding="{Binding FilterString, Mode=TwoWay}"
|
||||
Header="Filter"/>
|
||||
|
||||
<DataGridTemplateColumn Header="Move
Up">
|
||||
@@ -60,8 +60,8 @@
|
||||
Click="MoveUpButton_Clicked">
|
||||
<Button.IsEnabled>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<CompiledBinding Path="!IsTop" />
|
||||
<CompiledBinding Path="!IsDefault" />
|
||||
<Binding Path="!IsTop" />
|
||||
<Binding Path="!IsDefault" />
|
||||
</MultiBinding>
|
||||
</Button.IsEnabled>
|
||||
</Button>
|
||||
@@ -82,8 +82,8 @@
|
||||
Click="MoveDownButton_Clicked">
|
||||
<Button.IsEnabled>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.And}">
|
||||
<CompiledBinding Path="!IsBottom" />
|
||||
<CompiledBinding Path="!IsDefault" />
|
||||
<Binding Path="!IsBottom" />
|
||||
<Binding Path="!IsDefault" />
|
||||
</MultiBinding>
|
||||
</Button.IsEnabled>
|
||||
</Button>
|
||||
|
||||
@@ -86,7 +86,7 @@ public partial class EditQuickFilters : DialogWindow
|
||||
ReIndexFilters();
|
||||
}
|
||||
|
||||
protected override void SaveAndClose()
|
||||
public new void SaveAndClose()
|
||||
{
|
||||
QuickFilters.ReplaceAll(Filters.Select(x => x.AsNamedFilter()).OfType<QuickFilters.NamedFilter>());
|
||||
base.SaveAndClose();
|
||||
|
||||
@@ -21,13 +21,13 @@
|
||||
Grid.Column="0"
|
||||
Grid.Row="0"
|
||||
Margin="0,0,0,10"
|
||||
Text="{CompiledBinding Description}" />
|
||||
Text="{Binding Description}" />
|
||||
|
||||
<TextBox
|
||||
Grid.Column="0"
|
||||
Grid.Row="1"
|
||||
Name="userEditTbox"
|
||||
Text="{CompiledBinding UserTemplateText, Mode=TwoWay}" />
|
||||
Text="{Binding UserTemplateText, Mode=TwoWay}" />
|
||||
|
||||
<Button
|
||||
Grid.Column="1"
|
||||
@@ -36,7 +36,7 @@
|
||||
VerticalAlignment="Stretch"
|
||||
VerticalContentAlignment="Center"
|
||||
Content="Reset to Default"
|
||||
Command="{CompiledBinding ResetToDefault}"/>
|
||||
Command="{Binding ResetToDefault}"/>
|
||||
</Grid>
|
||||
<Grid Grid.Row="1" ColumnDefinitions="Auto,*"
|
||||
Margin="0,0,0,10">
|
||||
@@ -49,13 +49,13 @@
|
||||
GridLinesVisibility="All"
|
||||
AutoGenerateColumns="False"
|
||||
DoubleTapped="EditTemplateViewModel_DoubleTapped"
|
||||
ItemsSource="{CompiledBinding ListItems}" >
|
||||
ItemsSource="{Binding ListItems}" >
|
||||
|
||||
<DataGrid.Columns>
|
||||
<DataGridTemplateColumn Width="Auto" Header="Tag">
|
||||
<DataGridTemplateColumn.CellTemplate>
|
||||
<DataTemplate>
|
||||
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{CompiledBinding Item1}" />
|
||||
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -66,7 +66,7 @@
|
||||
<TextBlock
|
||||
Height="18"
|
||||
Margin="10,0,10,0"
|
||||
VerticalAlignment="Center" Text="{CompiledBinding Item2}" />
|
||||
VerticalAlignment="Center" Text="{Binding Item2}" />
|
||||
</DataTemplate>
|
||||
</DataGridTemplateColumn.CellTemplate>
|
||||
</DataGridTemplateColumn>
|
||||
@@ -91,7 +91,7 @@
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="WrapWithOverflow"
|
||||
Inlines="{CompiledBinding Inlines}" />
|
||||
Inlines="{Binding Inlines}" />
|
||||
|
||||
</Border>
|
||||
|
||||
@@ -99,8 +99,8 @@
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
Foreground="Firebrick"
|
||||
Text="{CompiledBinding WarningText}"
|
||||
IsVisible="{CompiledBinding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
Text="{Binding WarningText}"
|
||||
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
<controls:LinkLabel
|
||||
|
||||
@@ -19,10 +19,10 @@
|
||||
CanUserSortColumns="True"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="True"
|
||||
ItemsSource="{CompiledBinding Books}">
|
||||
ItemsSource="{Binding Books}">
|
||||
<DataGrid.Styles>
|
||||
<Style x:DataType="vm:BookDataViewModel" Selector="DataGridRow">
|
||||
<Setter Property="Background" Value="{CompiledBinding ScanStatus, Converter={x:Static dialogs:FindBetterQualityBooksDialog.RowConverter }}" />
|
||||
<Setter Property="Background" Value="{Binding ScanStatus, Converter={x:Static dialogs:FindBetterQualityBooksDialog.RowConverter }}" />
|
||||
</Style>
|
||||
</DataGrid.Styles>
|
||||
<DataGrid.Columns>
|
||||
@@ -30,51 +30,51 @@
|
||||
<DataGridTextColumn
|
||||
Width="120"
|
||||
IsReadOnly="False"
|
||||
Binding="{CompiledBinding Asin}"
|
||||
Binding="{Binding Asin}"
|
||||
Header="ASIN"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="120"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding Title}"
|
||||
Binding="{Binding Title}"
|
||||
Header="Title"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="120"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding FoundFile}"
|
||||
Binding="{Binding FoundFile}"
|
||||
Header="Best Found File"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding Codec}"
|
||||
Binding="{Binding Codec}"
|
||||
Header="Existing
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
SortMemberPath="Bitrate"
|
||||
Binding="{CompiledBinding BitrateString}"
|
||||
Binding="{Binding BitrateString}"
|
||||
Header="Existing
Bitrate"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding AvailableCodec}"
|
||||
Binding="{Binding AvailableCodec}"
|
||||
Header="Available
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
SortMemberPath="AvailableBitrate"
|
||||
Binding="{CompiledBinding AvailableBitrateString}"
|
||||
Binding="{Binding AvailableBitrateString}"
|
||||
Header="Available
Bitrate"/>
|
||||
|
||||
<DataGridCheckBoxColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding IsSignificant}"
|
||||
Binding="{Binding IsSignificant}"
|
||||
Header="Significantly
Greater?"/>
|
||||
|
||||
</DataGrid.Columns>
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
MinWidth="500" MinHeight="500"
|
||||
Width="500" Height="520"
|
||||
Title="Cover"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:ImageDisplayDialog+BitmapHolder"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Image Stretch="Uniform" Source="{Binding CoverImage}">
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
Width="800" Height="165"
|
||||
x:Class="LibationAvalonia.Dialogs.LibationFilesDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:LibationFilesDialog+DirSelectOptions"
|
||||
WindowStartupLocation="CenterScreen"
|
||||
Title="Book Details">
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace LibationAvalonia.Dialogs;
|
||||
|
||||
public partial class LibationFilesDialog : DialogWindow, ILibationInstallLocation
|
||||
{
|
||||
private class DirSelectOptions
|
||||
public class DirSelectOptions
|
||||
{
|
||||
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
|
||||
{
|
||||
|
||||
@@ -9,6 +9,8 @@
|
||||
MinHeight="135" MaxHeight="135"
|
||||
MinWidth="550" MaxWidth="550"
|
||||
Width="550" Height="135"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:LiberatedStatusBatchAutoDialog"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid Margin="10" RowDefinitions="Auto,Auto">
|
||||
|
||||
@@ -11,4 +11,8 @@ public partial class LiberatedStatusBatchAutoDialog : DialogWindow
|
||||
DataContext = this;
|
||||
ControlToFocusOnShow = SaveButton;
|
||||
}
|
||||
|
||||
// For compiled bindings
|
||||
public new void SaveAndClose() => base.SaveAndClose();
|
||||
|
||||
}
|
||||
@@ -9,6 +9,8 @@
|
||||
MinWidth="400" MinHeight="100"
|
||||
MaxWidth="400" MaxHeight="100"
|
||||
Width="400" Height="100"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:LiberatedStatusBatchManualDialog"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto" ColumnDefinitions="*,Auto">
|
||||
|
||||
@@ -6,7 +6,7 @@ namespace LibationAvalonia.Dialogs;
|
||||
|
||||
public partial class LiberatedStatusBatchManualDialog : DialogWindow
|
||||
{
|
||||
private class liberatedComboBoxItem
|
||||
public class liberatedComboBoxItem
|
||||
{
|
||||
public LiberatedStatus Status { get; set; }
|
||||
public string? Text { get; set; }
|
||||
@@ -27,11 +27,11 @@ public partial class LiberatedStatusBatchManualDialog : DialogWindow
|
||||
}
|
||||
}
|
||||
|
||||
public IList BookStatuses { get; } = new List<liberatedComboBoxItem>
|
||||
{
|
||||
public List<liberatedComboBoxItem> BookStatuses { get; } =
|
||||
[
|
||||
new liberatedComboBoxItem { Status = LiberatedStatus.Liberated, Text = "Downloaded" },
|
||||
new liberatedComboBoxItem { Status = LiberatedStatus.NotLiberated, Text = "Not Downloaded" },
|
||||
};
|
||||
];
|
||||
|
||||
public LiberatedStatusBatchManualDialog(bool isPdf) : this()
|
||||
{
|
||||
@@ -42,7 +42,7 @@ public partial class LiberatedStatusBatchManualDialog : DialogWindow
|
||||
public LiberatedStatusBatchManualDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
SelectedItem = BookStatuses[0] as liberatedComboBoxItem;
|
||||
SelectedItem = BookStatuses[0];
|
||||
DataContext = this;
|
||||
ControlToFocusOnShow = SaveButton;
|
||||
}
|
||||
|
||||
@@ -3,9 +3,11 @@ using Avalonia.Controls;
|
||||
using Avalonia.Platform.Storage;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Reactive.Linq;
|
||||
@@ -53,10 +55,28 @@ public partial class LocateAudiobooksDialog : DialogWindow
|
||||
{
|
||||
Title = "Select the folder to search for audiobooks",
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? "")
|
||||
};
|
||||
|
||||
var selectedFolder = (await StorageProvider.OpenFolderPickerAsync(folderPicker))?.SingleOrDefault()?.TryGetLocalPath();
|
||||
var start = FolderPickerInitialPath.GetExistingDirectoryOrNull(Configuration.Instance.Books?.Path ?? "");
|
||||
if (start is not null)
|
||||
{
|
||||
var loc = await StorageProvider.TryGetFolderFromPathAsync(start);
|
||||
if (loc is not null)
|
||||
folderPicker.SuggestedStartLocation = loc;
|
||||
}
|
||||
|
||||
IReadOnlyList<IStorageFolder> picked;
|
||||
try
|
||||
{
|
||||
picked = await StorageProvider.OpenFolderPickerAsync(folderPicker);
|
||||
}
|
||||
catch
|
||||
{
|
||||
folderPicker.SuggestedStartLocation = null;
|
||||
picked = await StorageProvider.OpenFolderPickerAsync(folderPicker);
|
||||
}
|
||||
|
||||
var selectedFolder = picked?.SingleOrDefault()?.TryGetLocalPath();
|
||||
|
||||
if (selectedFolder is null || !Directory.Exists(selectedFolder))
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ using Avalonia.Platform;
|
||||
using Avalonia.Threading;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
@@ -29,8 +30,33 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Configuration.Instance.UseWebView && await BrowserLoginAsync(choiceIn) is ChoiceOut external)
|
||||
return external;
|
||||
// Embedded browser is optional; honor UseWebView (getter also forces false under Linux Snap).
|
||||
if (Configuration.Instance.UseWebView)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await BrowserLoginAsync(choiceIn) is ChoiceOut external)
|
||||
return external;
|
||||
}
|
||||
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
{
|
||||
// Linux (e.g. missing WebKit2GTK): go straight to external browser — same outcome as turning off embedded sign-in.
|
||||
if (OperatingSystem.IsLinux())
|
||||
{
|
||||
Serilog.Log.Logger.Information(
|
||||
ex,
|
||||
"Embedded sign-in browser is not available; continuing with external browser sign-in.");
|
||||
}
|
||||
else
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
App.MainWindow,
|
||||
WebView2LoginErrorMessage.ExplainerBody,
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -45,19 +71,10 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
|
||||
private async Task<ChoiceOut?> BrowserLoginAsync(ChoiceIn shoiceIn)
|
||||
{
|
||||
try
|
||||
{
|
||||
return await BrowserLoginAsyncCore(shoiceIn);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "In-app browser failed; falling back to external browser");
|
||||
// Time-of-use: setting can change before Show (e.g. settings saved) — never open NativeWebView when disabled.
|
||||
if (!Configuration.Instance.UseWebView)
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ChoiceOut?> BrowserLoginAsyncCore(ChoiceIn shoiceIn)
|
||||
{
|
||||
TaskCompletionSource<ChoiceOut?> tcs = new();
|
||||
|
||||
NativeWebDialog dialog = new()
|
||||
@@ -98,12 +115,70 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
dialog.Source = new Uri(shoiceIn.LoginUrl);
|
||||
};
|
||||
|
||||
if (!Configuration.IsLinux && App.MainWindow is TopLevel topLevel)
|
||||
dialog.Show(topLevel);
|
||||
else
|
||||
dialog.Show();
|
||||
if (!Configuration.Instance.UseWebView)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialog.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* not shown */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
return await tcs.Task;
|
||||
// NativeWebDialog can fault on a posted dispatcher job (e.g. missing libwebkit2gtk on Linux), which bypasses a try/catch around Show().
|
||||
void onUnhandledException(object? sender, DispatcherUnhandledExceptionEventArgs e)
|
||||
{
|
||||
if (!WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(e.Exception))
|
||||
return;
|
||||
e.Handled = true;
|
||||
try
|
||||
{
|
||||
dialog.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* ignore */
|
||||
}
|
||||
tcs.TrySetException(e.Exception);
|
||||
}
|
||||
|
||||
Dispatcher.UIThread.UnhandledException += onUnhandledException;
|
||||
try
|
||||
{
|
||||
if (!Configuration.Instance.UseWebView)
|
||||
{
|
||||
try
|
||||
{
|
||||
dialog.Close();
|
||||
}
|
||||
catch
|
||||
{
|
||||
/* not shown */
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (!Configuration.IsLinux && App.MainWindow is TopLevel topLevel)
|
||||
dialog.Show(topLevel);
|
||||
else
|
||||
dialog.Show();
|
||||
}
|
||||
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
{
|
||||
tcs.TrySetException(ex);
|
||||
}
|
||||
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
Dispatcher.UIThread.UnhandledException -= onUnhandledException;
|
||||
}
|
||||
|
||||
void Dialog_EnvironmentRequested(object? sender, WebViewEnvironmentRequestedEventArgs e)
|
||||
{
|
||||
|
||||
@@ -2,13 +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="650" d:DesignHeight="500"
|
||||
Width="650" Height="500"
|
||||
mc:Ignorable="d" d:DesignWidth="720" d:DesignHeight="640"
|
||||
Width="720" Height="640"
|
||||
MinWidth="600" MinHeight="560"
|
||||
CanResize="True"
|
||||
WindowStartupLocation="CenterOwner"
|
||||
x:Class="LibationAvalonia.Dialogs.Login.LoginExternalDialog"
|
||||
xmlns:login="clr-namespace:LibationAvalonia.Dialogs.Login"
|
||||
x:DataType="login:LoginExternalDialog"
|
||||
Title="Audible Login External">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,*" ColumnDefinitions="*" Margin="5">
|
||||
<Grid RowDefinitions="Auto,Auto,2*,Auto,*" ColumnDefinitions="*" Margin="5">
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
@@ -33,7 +37,8 @@
|
||||
Grid.Row="2"
|
||||
Grid.Column="0"
|
||||
RowDefinitions="Auto,*,Auto"
|
||||
ColumnDefinitions="*,Auto">
|
||||
ColumnDefinitions="*,Auto"
|
||||
MinHeight="160">
|
||||
|
||||
<TextBlock
|
||||
Grid.Row="0"
|
||||
@@ -45,6 +50,7 @@
|
||||
Grid.Row="1"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
MinHeight="160"
|
||||
IsReadOnly="True"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ExternalLoginUrl}" />
|
||||
@@ -67,8 +73,7 @@
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="3"
|
||||
Orientation="Vertical"
|
||||
VerticalAlignment="Bottom">
|
||||
Orientation="Vertical">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
@@ -90,12 +95,15 @@
|
||||
Grid.Row="4"
|
||||
Grid.Column="0"
|
||||
Margin="0,5,0,5"
|
||||
RowDefinitions="*,Auto">
|
||||
RowDefinitions="*,Auto"
|
||||
MinHeight="120">
|
||||
|
||||
<TextBox
|
||||
Grid.Row="0"
|
||||
Grid.Column="0"
|
||||
Grid.ColumnSpan="2"
|
||||
MinHeight="100"
|
||||
AcceptsReturn="True"
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding ResponseUrl, Mode=TwoWay}" />
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input.Platform;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Linq;
|
||||
@@ -16,6 +17,7 @@ public partial class LoginExternalDialog : DialogWindow
|
||||
public LoginExternalDialog() : base(saveAndRestorePosition: false)
|
||||
{
|
||||
InitializeComponent();
|
||||
CanResize = true;
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
Width="600" Height="450"
|
||||
x:Class="LibationAvalonia.Dialogs.MessageBoxAlertAdminDialog"
|
||||
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
|
||||
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
|
||||
x:DataType="dialogs:MessageBoxAlertAdminDialog"
|
||||
Title="MessageBoxAlertAdminDialog"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
|
||||
@@ -24,7 +25,7 @@ public partial class MessageBoxAlertAdminDialog : DialogWindow
|
||||
{
|
||||
ErrorDescription = text;
|
||||
this.Title = caption;
|
||||
ExceptionMessage = $"{exception.Message}\r\n\r\n{exception.StackTrace}";
|
||||
ExceptionMessage = ExceptionDisplay.FormatMessageAndStackTrace(exception);
|
||||
DataContext = this;
|
||||
}
|
||||
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
mc:Ignorable="d" d:DesignWidth="265" d:DesignHeight="110"
|
||||
MinWidth="265" MinHeight="110"
|
||||
CanMaximize="False"
|
||||
CanMinimize="False"
|
||||
x:DataType="vm:MessageBoxViewModel"
|
||||
x:Class="LibationAvalonia.Dialogs.MessageBoxWindow"
|
||||
Title="{CompiledBinding Caption}" ShowInTaskbar="True">
|
||||
Title="{Binding Caption}" ShowInTaskbar="True">
|
||||
|
||||
<Grid ColumnDefinitions="*" RowDefinitions="*,Auto">
|
||||
|
||||
@@ -18,19 +20,19 @@
|
||||
<Panel Height="32" Width="32" Grid.Column="0" Margin="5,0,5,0" VerticalAlignment="Top">
|
||||
<Panel.IsVisible>
|
||||
<MultiBinding Converter="{x:Static BoolConverters.Or}">
|
||||
<CompiledBinding Path="IsAsterisk" />
|
||||
<CompiledBinding Path="IsError" />
|
||||
<CompiledBinding Path="IsQuestion" />
|
||||
<CompiledBinding Path="IsExclamation" />
|
||||
<Binding Path="IsAsterisk" />
|
||||
<Binding Path="IsError" />
|
||||
<Binding Path="IsQuestion" />
|
||||
<Binding Path="IsExclamation" />
|
||||
</MultiBinding>
|
||||
</Panel.IsVisible>
|
||||
<Image IsVisible="{CompiledBinding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
|
||||
<Image IsVisible="{CompiledBinding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
|
||||
<Image IsVisible="{Binding IsAsterisk}" Stretch="Uniform" Source="/Assets/MBIcons/Asterisk_64.png"/>
|
||||
<Image IsVisible="{Binding IsError}" Stretch="Uniform" Source="/Assets/MBIcons/Error_64.png"/>
|
||||
<Image IsVisible="{Binding IsQuestion}" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
|
||||
<Image IsVisible="{Binding IsExclamation}" Stretch="Uniform" Source="/Assets/MBIcons/Exclamation_64.png"/>
|
||||
</Panel>
|
||||
|
||||
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{CompiledBinding Message}" />
|
||||
<TextBlock Margin="5,0,0,0" Name="messageTextBlock" MinHeight="45" MinWidth="193" TextWrapping="WrapWithOverflow" HorizontalAlignment="Left" VerticalAlignment="Top" FontSize="12" Text="{Binding Message}" />
|
||||
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
@@ -38,13 +40,13 @@
|
||||
<DockPanel Height="45" Grid.Row="1" Background="{DynamicResource SystemChromeMediumLowColor}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
|
||||
<Button Grid.Column="0" MinWidth="75" MinHeight="28" Name="Button1" Click="Button1_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button1Text}"/>
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button1Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="1" IsVisible="{CompiledBinding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button2Text}"/>
|
||||
<Button Grid.Column="1" IsVisible="{Binding HasButton2}" MinWidth="75" MinHeight="28" Name="Button2" Click="Button2_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button2Text}"/>
|
||||
</Button>
|
||||
<Button Grid.Column="2" IsVisible="{CompiledBinding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{CompiledBinding Button3Text}"/>
|
||||
<Button Grid.Column="2" IsVisible="{Binding HasButton3}" MinWidth="75" MinHeight="28" Name="Button3" Click="Button3_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="{Binding Button3Text}"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
|
||||
@@ -49,26 +49,26 @@
|
||||
|
||||
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="STRING FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding StringUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding StringFields}"/>
|
||||
<TextBlock Grid.Row="1" Text="{Binding StringUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{Binding StringFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="NUMBER FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding NumberUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding NumberFields}"/>
|
||||
<TextBlock Grid.Row="1" Text="{Binding NumberUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{Binding NumberFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="2" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="BOOLEAN (TRUE/FALSE) FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding BoolUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding BoolFields}"/>
|
||||
<TextBlock Grid.Row="1" Text="{Binding BoolUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{Binding BoolFields}"/>
|
||||
</Grid>
|
||||
|
||||
<Grid Grid.Row="1" Grid.Column="3" RowDefinitions="Auto,Auto,*">
|
||||
<TextBlock Text="ID FIELDS" />
|
||||
<TextBlock Grid.Row="1" Text="{CompiledBinding IdUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{CompiledBinding IdFields}"/>
|
||||
<TextBlock Grid.Row="1" Text="{Binding IdUsage}" />
|
||||
<ListBox Grid.Row="2" DoubleTapped="ListBox_DoubleTapped" ItemsSource="{Binding IdFields}"/>
|
||||
</Grid>
|
||||
</Grid>
|
||||
</Window>
|
||||
Loaded 100 of 224 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user