Compare commits

...
212 Commits
Author SHA1 Message Date
rmcrackan 8f3f069d54 Merge pull request #1829 from rmcrackan/rmcrakan/13-4-2
incr ver
2026-05-19 08:57:00 -04:00
rmcrackan 88e3bbab89 Merge pull request #1828 from rmcrackan/rmcrackan/1827-cli-liberate-single
#1827 - CLI option to liberate single book
2026-05-19 08:56:49 -04:00
rmcrackan ca52ed4b7a incr ver 2026-05-19 08:55:57 -04:00
rmcrackan bb4a5298ae #1827 - CLI option to liberate single book 2026-05-19 08:51:21 -04:00
rmcrackan 9512e62deb Merge pull request #1824 from rmcrackan/rmcrackan/1819-ntfs-note
#1819 - Better NTFS invalid character note
2026-05-18 16:47:25 -04:00
rmcrackan 91174ed8dd Merge pull request #1826 from rmcrackan/rmcrackan/1823-full-disk
#1823 - Address full disk better
2026-05-18 10:09:33 -04:00
rmcrackan b9490966bb * Multi-step backup: PDF and later steps now run in the same awaited chain, so disk full on PDF stops the queue instead of counting as success.
* Detection: Quota / NAS “out of space” messages are treated like disk full, not generic retries.
2026-05-18 10:00:32 -04:00
rmcrackan d1114b410e sync/async bug fixes 2026-05-18 09:53:05 -04:00
rmcrackan 49e9b0c9ce oops, wrong branch. revert 2026-05-18 09:42:08 -04:00
rmcrackan 0b0d1afc48 Fix build failures on non-windows 2026-05-18 09:39:57 -04:00
rmcrackan 77f518f8d3 #1823 - Address full disk better. Fail clearly and early when possible, fail safely when space cannot be queried, and do not take down the UI or spam the user when the disk is full. 2026-05-18 09:20:51 -04:00
rmcrackan 9f1d4a383b Merge pull request #1825 from rmcrackan/rmcrackan/1822-crash-after-upgrade
#1822 - Fix the startup crash after auto-upgrade
2026-05-18 09:02:42 -04:00
rmcrackan 74b18c170a #1822 - Fix the startup crash after auto-upgrade by loading the library only after InteropFactory assembly resolution is ready, checking that Microsoft.EntityFrameworkCore.Sqlite.dll is present in the install folder, and showing a clear reinstall message instead of a generic "Unexpected error" when that still fails. 2026-05-18 08:54:40 -04:00
rmcrackan c85c11b27a #1819 - Better NTFS invalid character note 2026-05-18 08:21:51 -04:00
rmcrackan c8ac585ab3 Merge pull request #1817 from rmcrackan/rmcrackan/1811-threading
#1811 - fix UI threading
2026-05-15 12:10:39 -04:00
rmcrackan 55b45ae45d #1811 - fix UI threading 2026-05-15 12:02:23 -04:00
rmcrackan 776cd5215f Merge pull request #1816 from rmcrackan/rmcrackan/docker-yaml-context
Avoid default git context refs/pull/*/merge (breaks after PR merge or…
2026-05-15 11:45:03 -04:00
rmcrackan 1b8840c3b0 Avoid default git context refs/pull/*/merge (breaks after PR merge or on some re-runs) 2026-05-15 11:13:04 -04:00
rmcrackan 97537df933 Merge pull request #1815 from rmcrackan/rmcrackan/update-contribs
Add @CryptoJones to contributor list
2026-05-15 09:41:54 -04:00
rmcrackan eb4ab59737 Add @CryptoJones to contributor list 2026-05-15 09:41:33 -04:00
rmcrackan 55c2bf3be7 Merge pull request #1814 from CryptoJones/fix/tag-culture-test-de-ch-locale
Fix Tag_culture_test platform dependency on de-CH NumberGroupSeparator
2026-05-15 09:39:06 -04:00
AK ClarkandClaude Opus 4.7 ebce0c073b Fix Tag_culture_test platform dependency on de-CH NumberGroupSeparator
The de-CH row hard-coded U+2019 as the expected thousands separator,
which only matches certain .NET/ICU/CLDR data versions. Linux .NET hosts
return U+0027 (ASCII apostrophe) for the same culture, so every Linux CI
run failed this test regardless of the actual change under test.

Resolve U+2019 in DataRow expectations to the runtime culture's
NumberGroupSeparator before comparison so the test stays stable across
hosts while still verifying the engine respects culture-specific
formatting.

- Added DataRows for de-DE (period) and ja-JP (comma)
- Added Samplerate_template_uses_culture_NumberGroupSeparator as an
  explicit regression guard that asserts the engine uses whatever the
  runtime CultureInfo reports

Fixes #1813.

Verified locally: 584 tests pass, 0 failed (565 succeeded, 19 skipped
Windows-only).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-15 01:10:57 -05:00
rmcrackan 0499bbae7e Merge pull request #1803 from Jo-Be-Co/1762_filter
1762 add filter() to list properties
2026-05-14 11:29:26 -04:00
Jo-Be-Co 4be0361e42 fixing typos and naming of test method 2026-05-14 16:51:51 +02:00
Jo-Be-Co c2c7b04acd minor fixes and comment corrections 2026-05-14 02:12:25 +02:00
Jo-Be-Co f194032139 #1762 add filter() to list properties 2026-05-14 02:10:38 +02:00
Jo-Be-Co a21bb8174d #1762 introduce CommonFormatters.TryGetLiteral 2026-05-14 02:04:26 +02:00
rmcrackan 3fc5ab0f8d Merge pull request #1807 from rmcrackan/rmcrackan/acct-export-disable
Account 'export' should be disabled when the account has no keys/tokens
2026-05-12 12:52:34 -04:00
rmcrackan 591a6d1594 Account 'export' should be disabled when the account has no keys/tokens 2026-05-12 12:05:11 -04:00
rmcrackan c4ac900045 Merge pull request #1806 from rmcrackan/rmcrackan/1804-win-folder-picker-custom-paths
#1804 - Fix Windows folder picker crash on custom paths
2026-05-12 09:04:18 -04:00
rmcrackan 50fde66a4c #1804 - Fix Windows folder picker crash on custom paths. Normalize stored paths before opening the OS folder dialog (\\?\ / UNC handling, existence check) in shared FolderPickerInitialPath, use it from WinForms and Avalonia folder pickers, and retry once if the dialog still throws. Adds small FileManager tests. 2026-05-12 08:55:12 -04:00
rmcrackan 597c8f7b3d Merge pull request #1795 from Jo-Be-Co/1762_unique_and_count
#1762 add unique() and count() to list properties
2026-05-09 23:21:29 -04:00
Jo-Be-Co 4ffcbeb1db Fix typos 2026-05-10 00:37:02 +02:00
Jo-Be-Co 6996fb1b81 When formatting ContributorDto, retain the IFormatProvider even if the format is empty 2026-05-10 00:36:49 +02:00
Jo-Be-Co 7ef10b1e7d #1762 add unique() and count() to list properties 2026-05-09 21:15:24 +02:00
Jo-Be-Co 0a644695d8 #1762 extract comparison to separate reusable class 2026-05-09 00:59:35 +02:00
rmcrackan 86b50b45ef Merge pull request #1796 from rmcrackan/rmcrackan/1711-linux-safety
#1711 : Linux/Docker: the default in-progress download/decrypt folder…
2026-05-08 09:10:23 -04:00
rmcrackan 727c869ba9 #1711 : Linux/Docker: the default in-progress download/decrypt folder is now per-user (/tmp/Libation-<username>) and is created with 0700 permissions, fixing failures caused by leftover /tmp/Libation directories from previous root installs and hardening against another local user reading partial download artifacts. Auto-upgrade downloads now land in a randomly-named per-run temp directory. 2026-05-08 09:04:39 -04:00
Robert f330d8bed6 incr ver 2026-05-05 13:45:27 -04:00
rmcrackan 9aecd26aea Merge pull request #1791 from rmcrackan/rmcrackan/1732-bug-fixes
#1732 bug fixes
2026-05-05 13:41:32 -04:00
rmcrackan 0cc2ef773d * Default Scan library to on for new accounts from Upsert / Mkb79 import (matches GUI)
* CLI liberate: print short license-denial reasons to stderr
* GUI: message when stoplight can’t queue (e.g. absent from last scan)
2026-05-05 13:32:28 -04:00
Robert 78371e3522 incr ver 2026-05-05 09:41:26 -04:00
rmcrackan 24a896e61b Merge pull request #1790 from Mbucari/master
Update Avalonia to v12
2026-05-05 09:39:52 -04:00
MBucari e4daa5b8e8 Fix macos bundle build failure 2026-05-04 21:20:42 -06:00
MBucari 505c614210 Code Cleanup
Remove unused parameters
Remove unnecessary casts
Make fields readonly
Order modifiers
Format document
Sort usings
Remove unnecessary nullable directive
Apply namespace preferences (file-level)
2026-05-04 21:10:28 -06:00
Michael Bucari-Tovo df531de255 Update Avalonia to v12
Avalonia 12 uses compiled bindings by default. Converted all remaining reflection bindings into compiled bindings,

Fixed binding errors
2026-05-04 21:08:26 -06:00
rmcrackan 7b1dd64936 incr ver 2026-05-04 13:43:05 -04:00
rmcrackan c226e8ca38 Merge pull request #1789 from rmcrackan/rmcrackan/1732-feedback
Rmcrackan/1732 feedback
2026-05-04 13:42:01 -04:00
rmcrackan 673ff153b0 #1732 - new CLI list accounts option 2026-05-04 13:28:36 -04:00
rmcrackan 5b898dcd48 #1732 - fix: --help, -h, /?, /h, /help 2026-05-04 13:20:58 -04:00
rmcrackan b1df70f66c Merge pull request #1788 from rmcrackan/rmcrackan/1786-win-10
#1786 : win 10+ only
2026-05-04 11:06:02 -04:00
rmcrackan 220c916258 #1786 : win 10+ only 2026-05-04 11:05:32 -04:00
rmcrackan c3e0247940 Merge pull request #1787 from rmcrackan/rmcrackan/1783-chardonnay-x-filter
Bug fix #1783 : X to clear filter in Chardonnay
2026-05-04 10:12:01 -04:00
rmcrackan 0891fcf256 Bug fix #1783 : X to clear filter in Chardonnay 2026-05-04 10:02:18 -04:00
rmcrackan b259fdc9a5 Merge pull request #1785 from rmcrackan/rmcrackan/634-too-small
troubleshooting: if UI too small then AVALONIA_GLOBAL_SCALE_FACTOR
2026-05-04 09:35:56 -04:00
rmcrackan ecee08aaf7 troubleshooting: if UI too small then AVALONIA_GLOBAL_SCALE_FACTOR 2026-05-04 09:35:22 -04:00
rmcrackan b53a092327 Merge pull request #1784 from rmcrackan/rmcrackan/1199-docs
Add to linux troubleshooting, per solution in #1199
2026-05-04 09:07:21 -04:00
rmcrackan 0c838a483e Add to linux troubleshooting, per solution in #1199 2026-05-04 09:06:50 -04:00
rmcrackan d934511c32 incr ver 2026-05-01 10:11:51 -04:00
rmcrackan 8c88739644 Merge pull request #1747 from rmcrackan/rmcrackan/1732-cli-auth
add CLI auth options
2026-05-01 10:05:41 -04:00
rmcrackan 05e1de78da Merge pull request #1779 from rmcrackan/rmcrackan/1776-snap-docs
Make Linux Snap SQLite startup failures easier to understand
2026-05-01 10:01:45 -04:00
rmcrackan dd059a0ce9 #1776 - Make Linux Snap SQLite startup failures easier to understand and fix (stale LibationFiles in appsettings.json after a revision change), improve crash and log detail (inner exceptions + paths), and document that in the docs and FAQ. Tests lock in exception formatting behavior. 2026-05-01 10:01:03 -04:00
rmcrackan 4f401fe6c7 Merge pull request #1778 from rmcrackan/rmcrackan/1744-imgs
trying again to fix the intermittent cover image issue
2026-05-01 09:46:35 -04:00
rmcrackan 849f5f508e trying again to fix the intermittent cover image issue 2026-05-01 09:38:29 -04:00
rmcrackan ac2b5ef3e2 Merge pull request #1777 from rmcrackan/rmcrackan/1776-improve-errors
#1776. better messages to users. improve all exception display
2026-04-30 11:11:26 -04:00
rmcrackan 13bdebbe9d #1776. better messages to users. improve all exception display 2026-04-30 11:04:50 -04:00
rmcrackan 6445a758a2 Merge pull request #1772 from rmcrackan/dependabot/npm_and_yarn/postcss-8.5.12
Bump postcss from 8.5.6 to 8.5.12
2026-04-27 19:08:01 -04:00
dependabot[bot] f6b0765dbc Bump postcss from 8.5.6 to 8.5.12
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.6 to 8.5.12.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.6...8.5.12)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.12
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-27 23:04:47 +00:00
rmcrackan 95dfee130e Merge pull request #1771 from Mbucari/master
Re-add Category Name Column to database
2026-04-27 19:03:47 -04:00
Michael Bucari-Tovo b8616a6da0 Re-add Category Name Column to database
Category names were accidently removed during the last DB migration in d67692355f.

Re-add the Names column and modify CategoryImporter to upsert the names on update.
2026-04-27 14:45:13 -06:00
rmcrackan 0b975a168e Merge pull request #1769 from rmcrackan/rmcrackan/1744-folder-icons
#1744 Make Explorer folder icons reliable
2026-04-27 10:31:05 -04:00
rmcrackan f610ccdf58 #1744 Make Explorer folder icons reliable by running them after other post-download work, retrying briefly when the 300×300 cover or ICO write fails, and logging clear, actionable messages when it still cannot succeed. 2026-04-27 10:30:27 -04:00
rmcrackan f0c61ec7db typo 2026-04-23 11:20:32 -04:00
rmcrackan 14bd9d9deb Merge pull request #1759 from rmcrackan/rmcrackan/1694-mac-docs
Improve mac troubleshooting docs per discoveries in #1694
2026-04-23 11:13:42 -04:00
rmcrackan 01487690d4 Improve mac troubleshooting docs per discoveries in #1694 2026-04-23 11:13:17 -04:00
rmcrackan acfde519f5 Merge pull request #1758 from rmcrackan/rmcrackan/fix-naming-template
fix naming template markdown
2026-04-22 13:50:14 -04:00
rmcrackan 7fb6ea4701 fix {{ literal 2026-04-22 13:47:29 -04:00
rmcrackan db1566a32d This font is too clever with rendering things like <- as arrows. Let's just make this page monospace 2026-04-22 13:47:01 -04:00
rmcrackan ca48767cc1 syntax fixes 2026-04-22 10:45:57 -04:00
rmcrackan 310abe951a Merge pull request #1757 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-7
Bump apple-actions/import-codesign-certs from 6 to 7
2026-04-21 10:24:34 -04:00
dependabot[bot] aed2aec38e Bump apple-actions/import-codesign-certs from 6 to 7
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 6 to 7.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-21 14:14:34 +00:00
rmcrackan 531d98a0d5 Merge pull request #1756 from rmcrackan/rmcrackan/fix-docs
fix vitepress/vue syntax
2026-04-21 08:56:00 -04:00
rmcrackan dd8bba959d fix vitepress/vue syntax 2026-04-21 08:54:53 -04:00
Robert 9d6f0f7700 incr ver 2026-04-20 22:01:31 -04:00
rmcrackan 274cbf37ca Merge pull request #1755 from Jo-Be-Co/Conditional-output-on-format-templates
#1754 Conditional output on format templates
2026-04-20 21:59:02 -04:00
Jo-Be-Co 2b25ced65b documentation for format-templates 2026-04-21 00:18:14 +02:00
Jo-Be-Co 417e654799 #1754 Conditional output on format templates 2026-04-20 23:15:54 +02:00
rmcrackan e9550055c6 Merge pull request #1750 from Jo-Be-Co/refactor-format-escapes
fixed quoting errors in format-strings
2026-04-20 16:46:43 -04:00
Jo-Be-Co fa64be7ad0 fixed typoo ;-) 2026-04-20 22:41:34 +02:00
Jo-Be-Co e3508b2998 rebased and small fixes 2026-04-20 22:06:40 +02:00
Jo-Be-Co 30809fb38d fixed quoting errors in format-strings 2026-04-20 21:38:45 +02:00
rmcrackan 5b89f29a4f Merge pull request #1735 from Jo-Be-Co/bifunctions
1714 conditional tag cmp working on two properties
2026-04-20 14:36:24 -04:00
Jo-Be-Co ea33d8933a test added with colons in tag comparison 2026-04-20 19:24:21 +02:00
Jo-Be-Co c1e9911ade Small fixes on auto checks 2026-04-20 18:43:33 +02:00
Jo-Be-Co 962a5055b2 Fixed PR comments 2026-04-20 18:07:17 +02:00
rmcrackan 3108bfa469 Merge pull request #1753 from rmcrackan/rmcrackan/1748-upgrader
In-app upgrades now wait for the real installer and treat failures as failures
2026-04-20 10:21:13 -04:00
rmcrackan ccc74cef56 #1748: In-app upgrades now wait for the real installer and treat failures as failures. 2026-04-20 10:20:29 -04:00
rmcrackan 1de778adef Merge pull request #1752 from rmcrackan/rmcrackan/1751-resizeable
#1751 - text should always be readable
2026-04-20 09:48:38 -04:00
rmcrackan 4a30acf0eb #1751 - text should always be readable. Allow for user-resizing when it's not the case. 2026-04-20 09:48:13 -04:00
rmcrackan 6d326ebabc #1732 add CLI auth options. Add to the CLI these features which are already available in the GUI: importing from @mkb79 's audible-cli , and the external browser login (aka: alternative login). Caveat: 2nd feature doesn't work for Brazil (gui or cli) 2026-04-18 15:29:57 -04:00
rmcrackan f2ef616203 Merge pull request #1745 from Jo-Be-Co/refactor-LocaleDto
Some optimizations after last update to AudibleApi
2026-04-17 17:53:07 -04:00
Jo-Be-Co 87e8c6a32a fixed pr remarks 2026-04-17 23:48:22 +02:00
rmcrackan 62dfc20255 Merge pull request #1746 from rmcrackan/rmcrackan/1744-images
#1744 - attempt to harded code for cover image/folder image
2026-04-17 16:11:21 -04:00
rmcrackan 9f255a3bc1 #1744 - attempt to harded code for cover image/folder image 2026-04-17 16:07:00 -04:00
Jo-Be-Co 6d7af0a8ae Some optimizations after last update to AudibleApi 2026-04-17 21:43:42 +02:00
Jo-Be-Co 650b9c7f5b added docs for cmp tag 2026-04-17 21:22:30 +02:00
Jo-Be-Co dc353aa7e5 Merge branch 'master' into bifunctions 2026-04-17 08:50:53 +02:00
Jo-Be-Co d5258b7e64 fixed merge collision 2026-04-17 08:46:52 +02:00
Jo-Be-Co 475f20b3cb unit tests added for new list operations 2026-04-17 08:35:43 +02:00
rmcrackan 6efc64b927 incr ver 2026-04-16 12:27:11 -04:00
rmcrackan d2cc9ff26f Merge pull request #1743 from rmcrackan/rmcrackan/corrupt-search
Attempt to recover from corrupt search engine
2026-04-15 13:32:14 -04:00
rmcrackan cfe4770387 Attempt to recover from corrupt search engine 2026-04-15 13:24:26 -04:00
rmcrackan 33967adb15 Merge pull request #1742 from rmcrackan/rmcrackan/harden-webview
harden webview for linux
2026-04-15 10:22:39 -04:00
rmcrackan 9ef8dca4d5 Document Linux runtime dependency 2026-04-15 09:58:36 -04:00
rmcrackan af1029f4b5 Honor "don't use WebView" for this path 2026-04-15 09:50:18 -04:00
rmcrackan 63e7f0f44e Automatic fallback when WebKit is missing. If embedded WebView cannot load, route the same “add account” flow to non-WebView login, so Arch minimal installs work without WebKit. 2026-04-15 09:45:35 -04:00
rmcrackan 65623e8e81 Catch WebKit load failures on account login. Wrap the code path that opens NativeWebDialog / WebView for Audible login in a try/catch for DllNotFoundException (and related load failures). On failure, show a short dialog: install WebKit2GTK and do not crash the app. 2026-04-15 09:41:47 -04:00
rmcrackan a5912efa0c upgrade nuget 2026-04-15 08:10:09 -04:00
rmcrackan dba3e6ca8e Merge pull request #1725 from Jo-Be-Co/language_and_region
Extend <language> and <locale> tags
2026-04-15 08:00:49 -04:00
Jo-Be-Co a4bc39ee6b switched from RegionInfo to AudibleApi.Locale 2026-04-15 00:45:55 +02:00
rmcrackan 675fecec67 Merge pull request #1737 from rmcrackan/dependabot/github_actions/actions/upload-pages-artifact-5
Bump actions/upload-pages-artifact from 4 to 5
2026-04-14 10:22:55 -04:00
dependabot[bot] be1eac5692 Bump actions/upload-pages-artifact from 4 to 5
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-14 14:14:02 +00:00
Jo-Be-Co 8bad7d90c3 List os and ui tag in documentation 2026-04-14 03:04:26 +02:00
Jo-Be-Co c72e5a3568 fixed matching of first property 2026-04-14 02:48:03 +02:00
rmcrackan e145e46579 Merge pull request #1731 from Jo-Be-Co/1729_docker_resilient-creation-of-db-link
1729 docker resilient creation of db link
2026-04-13 19:38:44 -04:00
Jo-Be-Co 5fd8ac481b review fixes 2026-04-14 01:14:45 +02:00
Jo-Be-Co b6c7612ec4 Merge remote-tracking branch 'upstream/master' into language_and_region 2026-04-14 00:55:42 +02:00
Jo-Be-Co 3456eb6c35 Merge remote-tracking branch 'upstream/master' into 1729_docker_resilient-creation-of-db-link
# Conflicts:
#	Docker/liberate.sh
2026-04-14 00:51:09 +02:00
Jo-Be-Co c1b8cc894a add mathematical symbols 2026-04-14 00:41:22 +02:00
Jo-Be-Co 56c15d3108 add checks on lists 2026-04-14 00:38:41 +02:00
Jo-Be-Co 810ea90770 introduce cmp tag as check with two operands 2026-04-14 00:30:34 +02:00
rmcrackan b90cc67b09 Merge pull request #1734 from rmcrackan/dependabot/github_actions/softprops/action-gh-release-3
Bump softprops/action-gh-release from 2 to 3
2026-04-13 11:34:55 -04:00
dependabot[bot] 550a99ac90 Bump softprops/action-gh-release from 2 to 3
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 2 to 3.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v2...v3)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-version: '3'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-13 15:30:12 +00:00
rmcrackan 9ed89eca9e Merge pull request #1733 from rmcrackan/rmcrackan/1729-docker-tweak
#1729 minor docker tweak
2026-04-13 08:51:28 -04:00
rmcrackan 197d645e10 #1729 minor docker tweak 2026-04-13 08:51:07 -04:00
rmcrackan bdfc99f209 Merge pull request #1730 from rmcrackan/dependabot/github_actions/actions/github-script-9
Bump actions/github-script from 8 to 9
2026-04-13 08:37:44 -04:00
Jo-Be-Co a010da5251 #1714 Convert the evaluation of the 'is' tag into two-parameter logic 2026-04-13 01:39:16 +02:00
Jo-Be-Co 4c212e752f some refinements 2026-04-11 22:19:31 +02:00
Jo-Be-Co e8d2ac5c62 #1729 added resilience when creating the link to the database file 2026-04-11 21:22:54 +02:00
Jo-Be-Co 6c552623d9 Adding documentation for region and language tags 2026-04-11 01:59:49 +02:00
dependabot[bot] 5a0d62687c Bump actions/github-script from 8 to 9
Bumps [actions/github-script](https://github.com/actions/github-script) from 8 to 9.
- [Release notes](https://github.com/actions/github-script/releases)
- [Commits](https://github.com/actions/github-script/compare/v8...v9)

---
updated-dependencies:
- dependency-name: actions/github-script
  dependency-version: '9'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-04-10 14:13:05 +00:00
Jo-Be-Co f4e61d0445 hardend an commented unit tests for region output 2026-04-10 11:44:28 +02:00
Jo-Be-Co 54765fc181 Fixed cross-platform problems on region tests 2026-04-10 10:14:19 +02:00
Jo-Be-Co d292cb0472 Merge remote-tracking branch 'origin/language_and_region' into language_and_region
# Conflicts:
#	Source/_Tests/LibationFileManager.Tests/TemplatesTests.cs
2026-04-10 09:47:00 +02:00
Jo-Be-Co b4f48cb54c Extend <language> and <locale> tags to allow different outputs like ISO-codes or names in different languages 2026-04-10 09:43:11 +02:00
rmcrackan e7d08d30b0 Merge pull request #1727 from rmcrackan/rmcrackan/log-init-db
Log initial db state, esp to capture books in trash
2026-04-09 11:05:36 -04:00
rmcrackan 26946e8a87 Log initial db state, esp to capture books in trash 2026-04-09 10:57:41 -04:00
rmcrackan ecf1cf4404 Merge pull request #1726 from rmcrackan/rmcrackan/vulnerable-nuget
copilot fix to vulnerable nuget pkg
2026-04-09 10:03:54 -04:00
rmcrackan 8213521404 copilot fix to vulnerable nuget pkg 2026-04-09 09:57:41 -04:00
Jo-Be-Co 0ef73464b8 Extend <language> and <locale> tags to allow different outputs like ISO-codes or names in different languages 2026-04-09 02:33:05 +02:00
rmcrackan 3030b748d7 Merge pull request #1723 from rmcrackan/rmcrackan/sqlite-readonly
Better handling of sqlite readonly
2026-04-08 17:48:55 -04:00
rmcrackan ccef50d681 move new check into datalayer 2026-04-08 17:43:21 -04:00
rmcrackan 78e6d7c5c1 Better handling of sqlite readonly 2026-04-08 17:33:07 -04:00
rmcrackan 846b93c97c Merge pull request #1720 from rmcrackan/rmcrackan/1719-corrupt-search-engine
#1719 - add defensive code to recover from a corrupt search engine
2026-04-07 15:05:45 -04:00
rmcrackan b0e79faacf #1719 - add defensive code to recover from a corrupt search engine 2026-04-07 15:00:18 -04:00
rmcrackan 3fece6aa44 Merge pull request #1718 from rmcrackan/rmcrackan/1716
#1716 - fix NAS bug
2026-04-05 15:47:41 -04:00
rmcrackan a7c7bd9925 #1716 - fix NAS bug 2026-04-05 15:41:58 -04:00
rmcrackan 3373b0979a Merge pull request #1713 from Jo-Be-Co/first-tag
Add `<first tag[...]>`
2026-04-03 09:19:50 -04:00
Jo-Be-Co 3e3da2298e Add <first tag[...]> 2026-04-03 03:48:09 +02:00
rmcrackan a9cb560f95 Merge pull request #1709 from rmcrackan/rmcrackan/plus-warning
Give better feedback to user with 'plus' license denied
2026-03-31 17:42:51 -04:00
rmcrackan e4282f014e remove close issues search link 2026-03-31 17:38:32 -04:00
rmcrackan b694e319f7 Give better feedback to user when they're getting persistent 'license denied' errors which are likely caused by too many Plus downloads 2026-03-31 17:31:40 -04:00
rmcrackan 67e3e41b15 Merge pull request #1708 from rmcrackan/rmcrackan/1704-mkb79import
fix bug with importing mkb79 auth with website_cookies: null vs empty
2026-03-31 13:22:33 -04:00
rmcrackan ad2101ab8c fix bug with importing mkb79 auth with website_cookies: null vs empty 2026-03-30 15:10:55 -04:00
rmcrackan d7ea8eb8f1 Merge pull request #1707 from rmcrackan/rmcrackan/webview-err-msg
better error messages when webview is the problem
2026-03-30 14:22:51 -04:00
rmcrackan 5c1153e187 better error messages when webview is the problem. shouldn't appear like a scan error 2026-03-30 14:22:09 -04:00
rmcrackan e929ab3062 Merge pull request #1706 from rmcrackan/dependabot/github_actions/actions/configure-pages-6
Bump actions/configure-pages from 5 to 6
2026-03-30 11:43:22 -04:00
dependabot[bot] d64fd564ab Bump actions/configure-pages from 5 to 6
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 5 to 6.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-30 15:38:21 +00:00
rmcrackan 340b0a0d29 Merge pull request #1705 from rmcrackan/rmcrackan/fix-template-esc
fix escaped html tags
2026-03-30 11:27:51 -04:00
rmcrackan 1f217b3966 fix escaped html tags 2026-03-30 11:26:53 -04:00
rmcrackan accb43456b Merge pull request #1700 from rmcrackan/rmcrackan/fix-templates-docs
fix tags in templates doc
2026-03-27 16:22:57 -04:00
rmcrackan b3a781de5e fix tags in templates doc 2026-03-27 16:21:32 -04:00
rmcrackan 34613f7e97 Merge pull request #1699 from rmcrackan/rmcrackan-patch-1
Update naming-templates.md
2026-03-27 16:02:38 -04:00
rmcrackan 1def16a157 Update naming-templates.md 2026-03-27 16:02:27 -04:00
rmcrackan 33a209df3c Merge pull request #1690 from Jo-Be-Co/improve-formatting-tags
Improve formatting tags
2026-03-27 15:56:51 -04:00
Jo-Be-Co 2daf2da30a Test fixes 2026-03-27 09:11:00 +01:00
Jo-Be-Co dd2bc7c461 Documentation for TimeSpan. 2026-03-27 01:14:42 +01:00
Jo-Be-Co 1d1936734b Test hardening.
Ensure tests check error and warning output to confirm assertions are correctly formulated.
2026-03-27 00:06:17 +01:00
rmcrackan e7f6e71d3b Merge pull request #1698 from rmcrackan/1692-mac-rt-clk-attempt2
#1692 : 2nd attempt to fix mac rt-clk issue
2026-03-26 16:23:51 -04:00
rmcrackan 0da8af3b8f incr ver 2026-03-26 16:17:51 -04:00
rmcrackan f159bcdbb9 2nd attempt 2026-03-26 15:26:06 -04:00
rmcrackan fc85aa457c Merge pull request #1695 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 4 to 6
2026-03-26 10:40:55 -04:00
rmcrackan 687a51b7bc Merge pull request #1696 from rmcrackan/dependabot/github_actions/actions/deploy-pages-5
Bump actions/deploy-pages from 4 to 5
2026-03-26 10:40:25 -04:00
dependabot[bot] 477d0088e2 Bump actions/deploy-pages from 4 to 5
Bumps [actions/deploy-pages](https://github.com/actions/deploy-pages) from 4 to 5.
- [Release notes](https://github.com/actions/deploy-pages/releases)
- [Commits](https://github.com/actions/deploy-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/deploy-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:12:37 +00:00
dependabot[bot] 38138d8272 Bump actions/checkout from 4 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-26 14:12:33 +00:00
Jo-Be-Co fc92c633e1 No made up format for minutes as .NET already knows how to format them.
User documentation needs to follow.
2026-03-26 03:12:24 +01:00
Jo-Be-Co 3c71cb10b2 some minor fixes 2026-03-26 03:10:43 +01:00
rmcrackan 65d8adc018 Merge pull request #1693 from rmcrackan/rmcrackan/close-stale
#1532 -- issue cleanup. Close stale tickets. Thanks @HenraL !
2026-03-25 11:13:30 -04:00
rmcrackan 9e739e48d7 #1532 -- issue cleanup. Close stale tickets. Thanks @HenraL ! 2026-03-25 10:59:12 -04:00
Jo-Be-Co 6b26280aa8 Get formats for properties in given form to the formatters by no unescaping them in advance. 2026-03-25 14:23:03 +01:00
Jo-Be-Co 14d8bb2205 As stated in the "Naming Templates" documentation, support custom formatters on numbers.
Only trim spaces on tags where they are disruptive.
2026-03-25 02:34:32 +01:00
rmcrackan 0960edeafb Add contributor: Jo-Be-Co 2026-03-24 15:16:17 -04:00
Jo-Be-Co 6d2b0b952d - Inspect exception on regexp timeout
- special tests for checks with whitespace
- Escaping allows whitespace at the edges
2026-03-24 00:18:40 +01:00
Jo-Be-Co fb277cff81 Faulty regex patterns will throw InvalidOperationException during evaluation. 2026-03-23 15:29:32 +01:00
Jo-Be-Co dbd5af8ab0 QS second part:
Made Evaluate() more resilient.
Added tests to CommonFormatters.
2026-03-22 22:22:02 +01:00
Jo-Be-Co 02b9b4fa1c QS updates:
- fixed documentation
- regexp-checks running with timeout and culture-invariant matching
- changed check-building in ConditionalTagCollection to use NonNull parameters. So no warnings occure.
- add tests for <!is ...> and escaped chars
2026-03-22 19:18:55 +01:00
Jo-Be-Co 7ecd571364 Corrections to documentation and comments. 2026-03-22 13:55:28 +01:00
Jo-Be-Co 32f3e6a886 Introduces the slice() format directive 2026-03-22 13:55:27 +01:00
Jo-Be-Co e477c29890 New tags <minutes>, <tag> and <if abridged->...<-if abridged> 2026-03-22 13:55:27 +01:00
Jo-Be-Co a8621699c1 Add numerical checks for conditional tag.
- numbers are compared with their value
- strings are compared by checking the length
- Collections are evaluated on cheking their size
2026-03-22 13:55:27 +01:00
Jo-Be-Co cd9a070784 Allow two step formatting to get checkable values and not only single stringa
Introduce <is-> Tag. Like <has-> but with additional check on content.

Retrieve objects instead of string for conditions

Pass undefined formats as null instead of empty strings
2026-03-22 13:55:26 +01:00
Jo-Be-Co d8ce7cc9b0 Formats are now escapable and the evaluation of HasValue has been transformed into a configuration parameter. 2026-03-22 13:55:26 +01:00
Jo-Be-Co 1d6d4ff9ac GetTagExpression uses Dictionary for capture names from Regexes 2026-03-22 13:55:26 +01:00
Jo-Be-Co db3a810f47 Regular Expressions with capture names on coments 2026-03-22 13:55:26 +01:00
Jo-Be-Co 508ea1032d Tests are using empty values for various fields 2026-03-22 13:55:25 +01:00
Jo-Be-Co 10fc5837cb Allow sorting in reverse order by specifying the sort key with lowercase 2026-03-22 13:55:25 +01:00
Jo-Be-Co 812e0c3b60 Format details on fields specified in format. Introduce by colon.
Use dictionaries for field access and sorting.

Added sorting to series lists.
2026-03-22 13:55:25 +01:00
Jo-Be-Co d161bdfaeb Introduce culture parameter for formatting. 2026-03-22 13:55:24 +01:00
Jo-Be-Co a6aa14c7f8 Collect formatters into CommonFormatters 2026-03-22 13:55:24 +01:00
Jo-Be-Co 7dd55a2545 Refactorings 2026-03-22 13:55:24 +01:00
rmcrackan 1081dfe8df Merge pull request #1689 from rmcrackan/rmcrackan/mac-yaml
mac yaml. print stdout, stderr
2026-03-18 14:53:27 -04:00
rmcrackan 3f716cd1ac - **Fix:** When xcrun notarytool submit failed (e.g. Apple TOS / "sign agreement" required), the job failed immediately and the real error never appeared in the workflow log.
- **Change:** Capture notarytool stdout+stderr with `|| true` so the script always runs `echo "$RESPONSE"`, then fail the step explicitly if no submission id is found. The job still fails on errors, but the full notarytool output (including stderr) is now visible in the Actions log.
- **Result:** Failures like "you must accept the agreement" show up in the run so you don’t have to reproduce them locally to see the message.
2026-03-18 14:49:02 -04:00
224 changed files with 8568 additions and 1781 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
# https://editorconfig.org
root = true
[*.md]
charset = utf-8
+2
View File
@@ -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'.
+10
View File
@@ -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)'
+15 -6
View File
@@ -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 }}"
+3 -3
View File
@@ -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
+2
View File
@@ -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.`
);
+39
View File
@@ -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'],
});
}
+1 -1
View File
@@ -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>"
+66
View File
@@ -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
+29
View File
@@ -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
+9
View File
@@ -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
View File
@@ -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() {
+1
View File
@@ -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)
+1 -1
View File
@@ -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>
+50 -44
View File
@@ -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);
+3 -3
View File
@@ -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();
}
+88 -2
View File
@@ -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)
+2 -1
View File
@@ -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>
+76 -5
View File
@@ -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>
@@ -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>
@@ -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");
+9 -5
View File
@@ -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>
+1 -1
View File
@@ -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}";
+1 -1
View File
@@ -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;
+56 -1
View File
@@ -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);
}
}
+2 -2
View File
@@ -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);
}
+17 -15
View File
@@ -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);
}
}
+24 -4
View File
@@ -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);
}
}
}
}
+8 -5
View File
@@ -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;
+1 -1
View File
@@ -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();
}
}
}
+23 -14
View File
@@ -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;
}
}
+2 -2
View File
@@ -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" />
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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>
+17 -18
View File
@@ -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([]));
}
}
}
}
+1 -1
View File
@@ -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&#xa;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&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
SortMemberPath="Bitrate"
Binding="{CompiledBinding BitrateString}"
Binding="{Binding BitrateString}"
Header="Existing&#xa;Bitrate"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding AvailableCodec}"
Binding="{Binding AvailableCodec}"
Header="Available&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
SortMemberPath="AvailableBitrate"
Binding="{CompiledBinding AvailableBitrateString}"
Binding="{Binding AvailableBitrateString}"
Header="Available&#xa;Bitrate"/>
<DataGridCheckBoxColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding IsSignificant}"
Binding="{Binding IsSignificant}"
Header="Significantly&#xa;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