mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3fece6aa44 | ||
|
|
a7c7bd9925 | ||
|
|
3373b0979a | ||
|
|
3e3da2298e | ||
|
|
a9cb560f95 | ||
|
|
e4282f014e | ||
|
|
b694e319f7 | ||
|
|
67e3e41b15 | ||
|
|
ad2101ab8c | ||
|
|
d7ea8eb8f1 | ||
|
|
5c1153e187 | ||
|
|
e929ab3062 | ||
|
|
d64fd564ab | ||
|
|
340b0a0d29 | ||
|
|
1f217b3966 | ||
|
|
accb43456b | ||
|
|
b3a781de5e | ||
|
|
34613f7e97 | ||
|
|
1def16a157 | ||
|
|
33a209df3c | ||
|
|
2daf2da30a | ||
|
|
dd2bc7c461 | ||
|
|
1d1936734b | ||
|
|
e7f6e71d3b | ||
|
|
0da8af3b8f | ||
|
|
f159bcdbb9 | ||
|
|
fc85aa457c | ||
|
|
687a51b7bc | ||
|
|
477d0088e2 | ||
|
|
38138d8272 | ||
|
|
fc92c633e1 | ||
|
|
3c71cb10b2 | ||
|
|
65d8adc018 | ||
|
|
9e739e48d7 | ||
|
|
6b26280aa8 | ||
|
|
14d8bb2205 | ||
|
|
0960edeafb | ||
|
|
6d2b0b952d | ||
|
|
fb277cff81 | ||
|
|
dbd5af8ab0 | ||
|
|
02b9b4fa1c | ||
|
|
7ecd571364 | ||
|
|
32f3e6a886 | ||
|
|
e477c29890 | ||
|
|
a8621699c1 | ||
|
|
cd9a070784 | ||
|
|
d8ce7cc9b0 | ||
|
|
1d6d4ff9ac | ||
|
|
db3a810f47 | ||
|
|
508ea1032d | ||
|
|
10fc5837cb | ||
|
|
812e0c3b60 | ||
|
|
d161bdfaeb | ||
|
|
a6aa14c7f8 | ||
|
|
7dd55a2545 | ||
|
|
1081dfe8df | ||
|
|
3f716cd1ac | ||
|
|
338715d52a | ||
|
|
d275ad034b | ||
|
|
b5ecff7e27 | ||
|
|
3144506ad5 | ||
|
|
99629c3c25 | ||
|
|
3fdb8ef15d | ||
|
|
a43a700e37 | ||
|
|
80823bf15c | ||
|
|
0a576069df | ||
|
|
0b0f5184d2 | ||
|
|
24d825607d | ||
|
|
14e4ea4592 | ||
|
|
56720124b8 |
No files matched your search
@@ -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)'
|
||||
@@ -85,12 +85,21 @@ jobs:
|
||||
WAIT="--wait"
|
||||
fi
|
||||
echo "::debug::Submitting the disk image for notarization"
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
|
||||
# Capture stdout+stderr (2>&1). Use || true so that when notarytool fails (e.g. Apple TOS
|
||||
# agreement required), the script does not exit before we can print RESPONSE—otherwise the
|
||||
# job would fail with no visible error message in the workflow log.
|
||||
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) || true
|
||||
echo "$RESPONSE"
|
||||
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
|
||||
|
||||
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
|
||||
echo "::notice::Notary Submission Id: $SUBMISSION_ID"
|
||||
|
||||
# Re-fail the step if submit failed (e.g. no submission id). The job still fails, but the
|
||||
# output above is now visible in the log so we can see the real error (e.g. sign agreement).
|
||||
if [ -z "$SUBMISSION_ID" ]; then
|
||||
echo "::error::Notarization submit failed. See output above for details (e.g. Apple TOS agreement)."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
|
||||
echo "::debug::Stapling the notarization ticket to the disk image"
|
||||
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
|
||||
|
||||
@@ -45,7 +45,7 @@ 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
|
||||
@@ -66,4 +66,4 @@ jobs:
|
||||
steps:
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@v4
|
||||
uses: actions/deploy-pages@v5
|
||||
@@ -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@v8
|
||||
with:
|
||||
script: |
|
||||
const exemptLabel = 'exempt';
|
||||
const creators = new Set(['rmcrackan', 'mbucari']);
|
||||
|
||||
const owner = context.repo.owner;
|
||||
const repo = context.repo.repo;
|
||||
|
||||
const items = await github.paginate(github.rest.issues.listForRepo, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
let labeled = 0;
|
||||
let notCreator = 0;
|
||||
let alreadyExempt = 0;
|
||||
|
||||
for (const item of items) {
|
||||
const login = item.user && item.user.login;
|
||||
if (!login || !creators.has(login.toLowerCase())) {
|
||||
notCreator++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const names = item.labels.map((l) => l.name);
|
||||
if (names.some((n) => n.toLowerCase() === exemptLabel)) {
|
||||
alreadyExempt++;
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await github.rest.issues.addLabels({
|
||||
owner,
|
||||
repo,
|
||||
issue_number: item.number,
|
||||
labels: [exemptLabel],
|
||||
});
|
||||
labeled++;
|
||||
} catch (e) {
|
||||
core.warning(`#${item.number}: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
core.info(
|
||||
`Backfill done: added exempt to ${labeled} item(s); ` +
|
||||
`${alreadyExempt} already had exempt; ${notCreator} not from rmcrackan/Mbucari.`
|
||||
);
|
||||
@@ -0,0 +1,39 @@
|
||||
# Assigns the `exempt` label to issues and PRs opened by maintainer accounts so
|
||||
# stale.yml does not mark them stale. See issue #1532.
|
||||
# For issues/PRs opened before this existed, run workflow "Exempt creators backfill".
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/exempt-creators.yml
|
||||
---
|
||||
name: Exempt specific creators
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened]
|
||||
pull_request:
|
||||
types: [opened]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
exempt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Add exempt label
|
||||
if: >-
|
||||
(github.event_name == 'issues' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.issue.user.login)) ||
|
||||
(github.event_name == 'pull_request' &&
|
||||
contains(fromJSON('["rmcrackan","Mbucari"]'), github.event.pull_request.user.login))
|
||||
uses: actions/github-script@v8
|
||||
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'],
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
# Marks inactive issues/PRs stale and closes them after a further quiet period.
|
||||
# Exempt labels include `enhancement`, `exempt` (from exempt-creators.yml), and others below.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/stale.yml
|
||||
---
|
||||
name: Close stale issues and PRs
|
||||
|
||||
on:
|
||||
schedule:
|
||||
- cron: '0 0 * * *'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
|
||||
jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-close: 14
|
||||
operations-per-run: 100
|
||||
|
||||
stale-issue-message: |
|
||||
This issue has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If this issue is still relevant, please leave a comment to keep it open.
|
||||
Thank you for your contributions to Libation.
|
||||
|
||||
close-issue-message: |
|
||||
This issue has been automatically closed due to inactivity.
|
||||
|
||||
If you believe this issue is still relevant, please reopen it or create a new issue with updated information.
|
||||
Thank you for your understanding.
|
||||
|
||||
stale-issue-label: stale
|
||||
|
||||
stale-pr-message: |
|
||||
This pull request has been automatically marked as stale because it has not had any activity for 30 days.
|
||||
|
||||
It will be closed in 14 days if no further activity occurs.
|
||||
|
||||
If you're still working on this PR, please leave a comment to keep it open.
|
||||
Thank you for your contribution.
|
||||
|
||||
close-pr-message: |
|
||||
This pull request has been automatically closed due to inactivity.
|
||||
|
||||
If you'd like to continue working on this, please reopen the PR or submit a new one.
|
||||
Thank you for your contribution.
|
||||
|
||||
stale-pr-label: stale
|
||||
|
||||
exempt-issue-labels: 'priority: critical,priority: high,security,help wanted,good first issue,needs-discussion,enhancement,exempt'
|
||||
exempt-pr-labels: 'priority: critical,priority: high,security,enhancement,exempt'
|
||||
|
||||
exempt-all-milestones: true
|
||||
exempt-all-assignees: false
|
||||
exempt-draft-pr: true
|
||||
ascending: true
|
||||
remove-stale-when-updated: true
|
||||
debug-only: false
|
||||
@@ -0,0 +1,29 @@
|
||||
# Ensures labels referenced by exempt-creators / stale workflows exist.
|
||||
# Optional per #1532 — run workflow_dispatch if labels are missing.
|
||||
# Pattern: https://github.com/Asperguide/asper-header/blob/main/.github/workflows/sync-labels.yml
|
||||
---
|
||||
name: Sync labels
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
paths:
|
||||
- '.github/labels.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
with:
|
||||
manifest: .github/labels.yml
|
||||
prune: false
|
||||
@@ -58,9 +58,9 @@ mkdir -p $BUNDLE_MACOS
|
||||
|
||||
mv "${BIN_DIR}/"* $BUNDLE_MACOS
|
||||
|
||||
if [ $? -ne 0 ]
|
||||
then echo "Error moving ${BIN_DIR} files"
|
||||
exit
|
||||
if [ $? -ne 0 ]; then
|
||||
echo "Error moving ${BIN_DIR} files"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Make fileicon executable..."
|
||||
@@ -83,10 +83,11 @@ mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
|
||||
|
||||
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
|
||||
echo "Set LSArchitecturePriority to $PLIST_ARCH"
|
||||
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
|
||||
# Portable sed -i (BSD sed on macOS requires backup arg; use .bak then remove)
|
||||
sed -i.bak "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
|
||||
|
||||
echo "Set CFBundleVersion to $VERSION"
|
||||
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
|
||||
sed -i.bak "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak
|
||||
|
||||
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
|
||||
for n in "${delfiles[@]}"
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.3.0.1</Version>
|
||||
<Version>13.3.3.1</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -282,63 +282,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 (System.Diagnostics.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");
|
||||
}
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
if (InteropFactory.InteropFunctionsType is null)
|
||||
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
|
||||
}
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
@@ -389,8 +390,8 @@ public static class LibationScaffolding
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// different text to make it easier to identify in logs, vs the AggregateException case above
|
||||
Log.Logger.Error(ex, "Version check failed. General exception");
|
||||
// different text to make it easier to identify in logs, vs the AggregateException case above
|
||||
Log.Logger.Error(ex, "Version check failed. General exception");
|
||||
}
|
||||
return (null, null, null, false, false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,8 @@ namespace ApplicationServices;
|
||||
|
||||
public static class DbContexts
|
||||
{
|
||||
private static bool _sqliteDbValidated;
|
||||
|
||||
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
|
||||
public static LibationContext GetContext()
|
||||
{
|
||||
@@ -14,6 +16,14 @@ public static class DbContexts
|
||||
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
|
||||
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
|
||||
context.Database.Migrate();
|
||||
|
||||
// Validate SQLite DB file was created and is accessible (once per process; OS may delay availability)
|
||||
if (!_sqliteDbValidated && string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString))
|
||||
{
|
||||
EssentialFileValidator.ValidateCreatedAndReport(SqliteStorage.DatabasePath);
|
||||
_sqliteDbValidated = true;
|
||||
}
|
||||
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
{
|
||||
@@ -196,8 +267,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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,21 +66,24 @@ public static class UtilityExtensions
|
||||
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?.Trim(),
|
||||
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;
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
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);
|
||||
|
||||
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 (int.TryParse(match.Groups["left"].ValueSpan, 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 "";
|
||||
|
||||
// is this function is called from toString implementation of the IFormattable interface, we only get a IFormatProvider
|
||||
var culture = provider as CultureInfo ?? provider?.GetFormat(typeof(CultureInfo)) as CultureInfo;
|
||||
return CollapseSpacesAndTrimRegex().Replace(TagFormatRegex().Replace(templateString, GetValueForMatchingTag), "");
|
||||
|
||||
string GetValueForMatchingTag(Match m)
|
||||
{
|
||||
var tag = m.Groups["tag"].Value;
|
||||
if (!replacements.TryGetValue(tag, out var getter)) return m.Value;
|
||||
|
||||
var value = getter(toFormat);
|
||||
var format = m.Groups["format"].ValueOrNull();
|
||||
return value switch
|
||||
{
|
||||
IFormattable formattable => formattable.ToString(format, provider),
|
||||
_ => _StringFormatter(value?.ToString(), format, culture),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 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("""\{(?<tag>[A-Z]+|#)(?::(?<format>(?:\\.|'(?:[^']|'')*'|"(?:[^"]|"")*"|.)*?))?\}""", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex TagFormatRegex();
|
||||
|
||||
public static string FormattableFormatter(ITemplateTag _, IFormattable? value, string? formatString, CultureInfo? culture)
|
||||
=> value?.ToString(formatString, culture) ?? "";
|
||||
|
||||
public static string IntegerFormatter(ITemplateTag templateTag, int value, string? formatString, CultureInfo? culture)
|
||||
=> FloatFormatter(templateTag, value, formatString, culture);
|
||||
|
||||
public static string FloatFormatter(ITemplateTag _, 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
|
||||
var strValue = value.ToString(culture);
|
||||
var decIndex = culture.CompareInfo.IndexOf(strValue, culture.NumberFormat.NumberDecimalSeparator);
|
||||
var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex);
|
||||
|
||||
return new string('0', zeroPad) + strValue;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
// 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 allways the next character. Especially further '\' and the closing ']'
|
||||
| '(?:[^']|'')*' # - 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 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 allways the next character. Especially further '\' and the closing ']'
|
||||
| '(?:[^']|'')*' # - 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
|
||||
)* [\#,.%‰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();
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Linq.Expressions;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -21,15 +24,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? value, CultureInfo? culture);
|
||||
|
||||
public partial 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,43 +45,241 @@ 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));
|
||||
}
|
||||
|
||||
private class ConditionalTag : TagBase, IClosingPropertyTag
|
||||
/// <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));
|
||||
}
|
||||
|
||||
private partial class ConditionalTag : TagBase, IClosingPropertyTag
|
||||
{
|
||||
private static readonly TimeSpan RegexpCheckTimeout = TimeSpan.FromMilliseconds(100);
|
||||
|
||||
public override Regex NameMatcher { get; }
|
||||
public Regex NameCloseMatcher { get; }
|
||||
|
||||
private Func<string?, Expression> CreateConditionExpression { get; }
|
||||
private Func<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 '<'. Condtionals 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(templateTag, parameter, valueProvider, property, conditionEvaluator);
|
||||
}
|
||||
|
||||
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` 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 '<'. Condtionals 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> # - capture inner part as <check>
|
||||
(?:\\. # - '\' escapes allways the next character. Especially further '\' and the closing ']'
|
||||
|[^\\\]])* ) # - match any character except '\' and ']'. Check may end in whitespace!
|
||||
\])? # - closing the check part
|
||||
)? # 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);
|
||||
|
||||
CreateConditionExpression = (exactName, property, checkString) =>
|
||||
{
|
||||
var conditionEvaluator = GetPredicate(exactName, checkString);
|
||||
return ConditionEvaluatorCall(templateTag, parameter, valueProvider, property, conditionEvaluator);
|
||||
};
|
||||
}
|
||||
|
||||
private static MethodCallExpression ConditionEvaluatorCall(ITemplateTag templateTag, ParameterExpression parameter, ValueProvider<TClass> valueProvider, string? property,
|
||||
ConditionEvaluator conditionEvaluator)
|
||||
{
|
||||
return Expression.Call(
|
||||
conditionEvaluator.Target is null ? null : Expression.Constant(conditionEvaluator.Target),
|
||||
conditionEvaluator.Method,
|
||||
ValueProviderCall(templateTag, parameter, valueProvider, property),
|
||||
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 ConditionEvaluator GetPredicate(string exactName, string? checkString)
|
||||
{
|
||||
if (checkString == null)
|
||||
return (v, _) => v switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(),
|
||||
_ => !string.IsNullOrWhiteSpace(v.ToString())
|
||||
};
|
||||
|
||||
var match = CheckRegex().Match(checkString);
|
||||
|
||||
var valStr = Unescape(match.Groups["val"]) ?? "";
|
||||
var iVal = -1;
|
||||
var isNumericalOperator = match.Groups["num_op"].Success && int.TryParse(valStr, out iVal);
|
||||
|
||||
var checkItem = Unescape(match.Groups["op"]) switch
|
||||
{
|
||||
"=" or "" => (v, culture) => VComparedToStr(v, culture, valStr) == 0,
|
||||
"!=" or "!" => (v, culture) => VComparedToStr(v, culture, valStr) != 0,
|
||||
"~" => GetRegExpCheck(exactName, valStr),
|
||||
"#=" => (v, _) => VAsInt(v) == iVal,
|
||||
"#!=" => (v, _) => VAsInt(v) != iVal,
|
||||
"#>=" or ">=" => (v, _) => VAsInt(v) >= iVal,
|
||||
"#>" or ">" => (v, _) => VAsInt(v) > iVal,
|
||||
"#<=" or "<=" => (v, _) => VAsInt(v) <= iVal,
|
||||
"#<" or "<" => (v, _) => VAsInt(v) < iVal,
|
||||
_ => (v, _) => !string.IsNullOrWhiteSpace(v.ToString())
|
||||
};
|
||||
return isNumericalOperator
|
||||
? (v, culture) => v switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => checkItem(e.Count(), culture),
|
||||
string s => checkItem(s.Length, culture),
|
||||
TimeSpan ts => checkItem(ts.TotalMinutes, culture),
|
||||
_ => checkItem(v, culture)
|
||||
}
|
||||
: (v, culture) => v switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(o => checkItem(o, culture)),
|
||||
_ => checkItem(v, culture)
|
||||
};
|
||||
|
||||
int? VAsInt(object v) => v is int iv ? iv : int.TryParse(v.ToString(), out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static int VComparedToStr(object? v, CultureInfo? culture, string valStr)
|
||||
{
|
||||
culture ??= CultureInfo.CurrentCulture;
|
||||
return culture.CompareInfo.Compare(v?.ToString(), valStr, CompareOptions.IgnoreCase);
|
||||
}
|
||||
|
||||
/// <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>
|
||||
/// <param name="pattern">The regex pattern to match</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, CultureInfo?, bool> GetRegExpCheck(string exactName, string pattern)
|
||||
{
|
||||
Regex regex;
|
||||
try
|
||||
{
|
||||
// Compile regex with timeout to prevent catastrophic backtracking
|
||||
regex = new Regex(pattern,
|
||||
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant | RegexOptions.Compiled,
|
||||
RegexpCheckTimeout);
|
||||
}
|
||||
catch (ArgumentException ex)
|
||||
{
|
||||
// If regex compilation fails, throw as faulty user input
|
||||
var errorMessage = BuildErrorMessage(exactName, pattern, "Invalid regular expression pattern. Correct the pattern and escaping or remove that condition");
|
||||
throw new InvalidOperationException(errorMessage, ex);
|
||||
}
|
||||
|
||||
return (v, _) =>
|
||||
{
|
||||
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(v.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 full message with pattern
|
||||
var fullMsg = $"{errorType}: {exactName} -> Pattern: {pattern}";
|
||||
|
||||
// Return 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}";
|
||||
|
||||
}
|
||||
|
||||
// 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 +295,28 @@ 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")?.ValueOrNull());
|
||||
return matchData["not"].Success ? Expression.Not(getBool) : getBool;
|
||||
}
|
||||
|
||||
[GeneratedRegex("""
|
||||
(?x) # option x: ignore all unescaped whitespace in pattern and allow comments starting with #
|
||||
^(?<op>(?<num_op> # anchor at start of linecapture operator in <op> and <num_op> with every char escapable
|
||||
\\?\#(?:\\?!)?\\?= # - 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();
|
||||
}
|
||||
}
|
||||
@@ -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,56 +31,76 @@ 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>
|
||||
/// <param name="template">The template string to parse</param>
|
||||
/// <param name="tagCollections">A collection of <see cref="TagCollection"/> with
|
||||
/// 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[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.Length != 4
|
||||
|| parameters[0].ParameterType != typeof(ITemplateTag)
|
||||
|| 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,89 @@ 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 allways the next character. Especially further '\' and the closing ']'
|
||||
|'(?:[^']|'')*' # - 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 character except '\' and ']'. Format may end in whitespace!
|
||||
\] # - 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 +253,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,43 @@
|
||||
using System;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace FileManager.NamingTemplate;
|
||||
|
||||
public static class RegExpExtensions
|
||||
{
|
||||
extension(Group group)
|
||||
{
|
||||
public string? ValueOrNull() => group.Success ? group.Value : null;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
extension(Regex regex)
|
||||
{
|
||||
public bool TryMatch(string input, [NotNullWhen(true)] out Match? match)
|
||||
{
|
||||
var m = regex.Match(input);
|
||||
match = m.Success ? m : null;
|
||||
return m.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,36 @@ internal abstract class TagBase : IPropertyTag
|
||||
return false;
|
||||
}
|
||||
|
||||
protected string TagNameForRegex()
|
||||
{
|
||||
return TemplateTag.TagName.Replace(" ", @"\s*").Replace("#", @"\#");
|
||||
}
|
||||
|
||||
protected static string? Unescape(Group? group)
|
||||
{
|
||||
return group?.Success ?? false ? Unescape(group.ValueSpan) : null;
|
||||
}
|
||||
|
||||
protected static string Unescape(ReadOnlySpan<char> valueSpan)
|
||||
{
|
||||
if (valueSpan.IsEmpty) return "";
|
||||
|
||||
var first = valueSpan.IndexOf('\\');
|
||||
if (first < 0)
|
||||
return valueSpan.ToString();
|
||||
|
||||
var sb = new StringBuilder(valueSpan.Length);
|
||||
sb.Append(valueSpan[..first]);
|
||||
for (var i = first; i < valueSpan.Length; i++)
|
||||
{
|
||||
if (valueSpan[i] == '\\' && i + 1 < valueSpan.Length)
|
||||
i++; // skip backslash and take the next char
|
||||
sb.Append(valueSpan[i]);
|
||||
}
|
||||
|
||||
return sb.ToString();
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
@@ -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"
|
||||
@@ -30,7 +30,8 @@
|
||||
<TextBlock Text="{CompiledBinding ImportEpisodesText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding ImportPlusTitles, Mode=TwoWay}">
|
||||
<CheckBox IsChecked="{CompiledBinding ImportPlusTitles, Mode=TwoWay}"
|
||||
ToolTip.Tip="{CompiledBinding ImportPlusTitlesTip}">
|
||||
<TextBlock Text="{CompiledBinding ImportPlusTitlesText}" />
|
||||
</CheckBox>
|
||||
|
||||
|
||||
@@ -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,22 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Configuration.Instance.UseWebView && await BrowserLoginAsync(choiceIn) is ChoiceOut external)
|
||||
return external;
|
||||
if (Configuration.Instance.UseWebView)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (await BrowserLoginAsync(choiceIn) is ChoiceOut external)
|
||||
return external;
|
||||
}
|
||||
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
App.MainWindow,
|
||||
WebView2LoginErrorMessage.ExplainerBody,
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -44,19 +59,6 @@ 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");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task<ChoiceOut?> BrowserLoginAsyncCore(ChoiceIn shoiceIn)
|
||||
{
|
||||
TaskCompletionSource<ChoiceOut?> tcs = new();
|
||||
|
||||
@@ -82,6 +84,8 @@ public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
{
|
||||
foreach (System.Net.Cookie c in shoiceIn.SignInCookies ?? [])
|
||||
{
|
||||
if (string.IsNullOrEmpty(c.Value))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
cookieManager.AddOrUpdateCookie(c);
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Threading;
|
||||
using FileManager;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI.Avalonia;
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
@@ -40,6 +41,9 @@ static class Program
|
||||
}
|
||||
AppDomain.CurrentDomain.UnhandledException += CurrentDomain_UnhandledException;
|
||||
|
||||
// When essential file validation fails and the error cannot be written to the log, show the user
|
||||
EssentialFileValidator.ShowUserWhenLogUnavailable = msg => Dispatcher.UIThread.Post(() => _ = MessageBoxBase.Show(null, msg, "Libation - Essential File Error", MessageBoxButtons.OK, MessageBoxIcon.Warning));
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
|
||||
@@ -3,6 +3,7 @@ using AudibleUtilities;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
@@ -221,11 +222,22 @@ public partial class MainVM
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
MainWindow,
|
||||
"Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator",
|
||||
"Error importing library",
|
||||
ex);
|
||||
if (WebView2LoginErrorMessage.TryFindInTree(ex, out var webViewEx) && webViewEx is not null)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
MainWindow,
|
||||
WebView2LoginErrorMessage.ExplainerBody,
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
webViewEx);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
MainWindow,
|
||||
"Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator",
|
||||
"Error importing library",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase.GridView;
|
||||
using ReactiveUI;
|
||||
@@ -434,11 +435,22 @@ public class ProductsDisplayViewModel : ViewModelBase
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
null,
|
||||
"Error scanning library. You may still manually select books to remove from Libation's library.",
|
||||
"Error scanning library",
|
||||
ex);
|
||||
if (WebView2LoginErrorMessage.TryFindInTree(ex, out var webViewEx) && webViewEx is not null)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
null,
|
||||
WebView2LoginErrorMessage.ExplainerBody,
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
webViewEx);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
null,
|
||||
"Error scanning library. You may still manually select books to remove from Libation's library.",
|
||||
"Error scanning library",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -28,6 +28,7 @@ public class ImportSettingsVM
|
||||
public string ShowImportedStatsText { get; } = Configuration.GetDescription(nameof(Configuration.ShowImportedStats));
|
||||
public string ImportEpisodesText { get; } = Configuration.GetDescription(nameof(Configuration.ImportEpisodes));
|
||||
public string ImportPlusTitlesText { get; } = Configuration.GetDescription(nameof(Configuration.ImportPlusTitles));
|
||||
public string ImportPlusTitlesTip => Configuration.ImportPlusTitlesToolTip;
|
||||
public string DownloadEpisodesText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadEpisodes));
|
||||
public string AutoDownloadEpisodesText { get; } = Configuration.GetDescription(nameof(Configuration.AutoDownloadEpisodes));
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Input.Platform;
|
||||
using Avalonia.Interactivity;
|
||||
using Avalonia.VisualTree;
|
||||
using Avalonia.Platform.Storage;
|
||||
using Avalonia.Styling;
|
||||
using DataLayer;
|
||||
@@ -105,6 +108,46 @@ public partial class ProductsDisplay : UserControl
|
||||
{
|
||||
column.CustomSortComparer = new RowComparer(column);
|
||||
}
|
||||
|
||||
// macOS: control-click may be delivered as left+control or as a secondary click; the default
|
||||
// ContextMenu route can fail on DataGrid cells. Open explicitly after children handle the event.
|
||||
if (Configuration.IsMacOs)
|
||||
{
|
||||
productsGrid.AddHandler(InputElement.PointerPressedEvent, ProductsGrid_PointerPressedMacContextMenu, RoutingStrategies.Bubble, handledEventsToo: true);
|
||||
}
|
||||
}
|
||||
|
||||
private void ProductsGrid_PointerPressedMacContextMenu(object? sender, PointerPressedEventArgs e)
|
||||
{
|
||||
if (DisableContextMenu)
|
||||
return;
|
||||
|
||||
var point = e.GetCurrentPoint(productsGrid);
|
||||
var props = point.Properties;
|
||||
bool isContextGesture = props.IsRightButtonPressed
|
||||
|| (props.IsLeftButtonPressed && e.KeyModifiers.HasFlag(KeyModifiers.Control));
|
||||
|
||||
if (!isContextGesture)
|
||||
return;
|
||||
|
||||
if (e.Source is not Visual visual)
|
||||
return;
|
||||
|
||||
DataGridCell? cell = null;
|
||||
for (Visual? v = visual; v is not null; v = v.GetVisualParent())
|
||||
{
|
||||
if (v is DataGridCell c)
|
||||
{
|
||||
cell = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (cell?.ContextMenu is not { } contextMenu)
|
||||
return;
|
||||
|
||||
contextMenu.Open(cell);
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
protected override void OnApplyTemplate(Avalonia.Controls.Primitives.TemplateAppliedEventArgs e)
|
||||
|
||||
@@ -22,7 +22,7 @@ internal class Walkthrough
|
||||
{
|
||||
private readonly Dictionary<string, string> settingTabMessages = new()
|
||||
{
|
||||
{ "Important Settings", "From here you can change where liberated books are stored and how detailed Libation's logs are.\r\n\r\nIf you experience a problem and need help, you'll be asked to provide your log file. In certain circumstances we may need you to reproduce the error with a higher level of logging detail."},
|
||||
{ "Important Settings", "From here you can change where liberated books are stored and how detailed Libation's logs are.\r\n\r\nIf you experience a problem and need help, you'll be asked to provide your log file. In certain circumstances we may need you to reproduce the error with a higher level of logging detail.\r\n\r\nFor best use with screen readers, uncheck \"Use Libation's built-in web browser to log into Audible?\"."},
|
||||
{ "Import Library", "In this tab you can change how your library is scanned and imported into Libation, as well as automatic liberation."},
|
||||
{ "Download/Decrypt", "These settings allow you to control how liberated files and folders are named and stored.\r\nYou can customize the 'Naming Templates' to use any number of the audiobook's properties to build a customized file and folder naming format. Learn more about the syntax from the wiki at\r\n\r\n" + LibationScaffolding.NamingTemplatesDocUrl},
|
||||
{ "Audio File Settings", "Control how audio files are decrypted, including audio format and metadata handling.\r\n\r\nIf you choose to split your audiobook into multiple files by chapter marker, you may edit the chapter file 'Naming Template' to control how each chapter file is named."},
|
||||
|
||||
@@ -140,9 +140,17 @@ public partial class Configuration
|
||||
For an audio file to be identified, Libation must have that library book in its database. If you're on a fresh installation of Libation, be sure to add and scan all of your Audible accounts before running this action.
|
||||
|
||||
This may take a while, depending on the number of audio files in the folder and the speed of your storage device.
|
||||
""" },
|
||||
{nameof(ImportPlusTitles), """
|
||||
When enabled, books from the Audible Plus catalog (titles you stream or borrow under your membership, not purchased) are imported into Libation.
|
||||
|
||||
Downloading or liberating many Plus titles in a short time can cause Audible to temporarily deny content licenses ("license denied") for a day or two. That limit is enforced by Audible, not Libation — waiting and retrying usually fixes it. If problems persist after several days, report on Libation's GitHub with logs.
|
||||
""" }
|
||||
}.AsReadOnly();
|
||||
|
||||
/// <summary>Tooltip for the Import Audible Plus books checkbox. Use from WinForms and Avalonia import settings so both stay aligned.</summary>
|
||||
public static string ImportPlusTitlesToolTip => GetHelpText(nameof(ImportPlusTitles));
|
||||
|
||||
public static string GetHelpText(string? settingName)
|
||||
=> settingName != null && HelpText.TryGetValue(settingName, out var value) ? value : "";
|
||||
}
|
||||
@@ -346,8 +346,8 @@ public partial class Configuration
|
||||
[Description("Use Libation's built-in web browser to log into Audible?")]
|
||||
public bool UseWebView
|
||||
{
|
||||
get => Configuration.IsRunningUnderSnap ? false : GetNonString(defaultValue: true);
|
||||
set { if (!Configuration.IsRunningUnderSnap) SetNonString(value); }
|
||||
get => IsRunningUnderSnap ? false : GetNonString(defaultValue: true);
|
||||
set { if (!IsRunningUnderSnap) SetNonString(value); }
|
||||
}
|
||||
|
||||
[Description("Auto download books? After scan, download new books in 'checked' accounts.")]
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
using Serilog;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading;
|
||||
|
||||
namespace LibationFileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that essential files were created correctly after creation, with retries to allow for OS delay.
|
||||
/// Callers should use <see cref="ReportValidationFailure"/> to log errors and show the user when the log cannot be written.
|
||||
/// </summary>
|
||||
public static class EssentialFileValidator
|
||||
{
|
||||
/// <summary>
|
||||
/// Called when an essential file validation fails and the error could not be written to the log.
|
||||
/// Set by the host (e.g. LibationAvalonia, LibationWinForms) to display the message to the user.
|
||||
/// </summary>
|
||||
public static Action<string>? ShowUserWhenLogUnavailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Default retry total duration (ms) when checking that a file is available after creation.
|
||||
/// </summary>
|
||||
public const int DefaultMaxRetriesMs = 1000;
|
||||
|
||||
/// <summary>
|
||||
/// Default delay (ms) between retries.
|
||||
/// </summary>
|
||||
public const int DefaultDelayMs = 50;
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the file at <paramref name="path"/> exists and is readable and writable,
|
||||
/// with retries to allow for OS delay between create and availability.
|
||||
/// Error messages use the file name portion of <paramref name="path"/>.
|
||||
/// </summary>
|
||||
/// <param name="path">Full path to the file.</param>
|
||||
/// <param name="maxRetriesMs">Total time to retry (ms).</param>
|
||||
/// <param name="delayMs">Delay between retries (ms).</param>
|
||||
/// <returns>(true, null) if valid; (false, errorMessage) if validation failed.</returns>
|
||||
public static (bool success, string? errorMessage) ValidateCreated(
|
||||
string path,
|
||||
int maxRetriesMs = DefaultMaxRetriesMs,
|
||||
int delayMs = DefaultDelayMs)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(path))
|
||||
return (false, "(unknown file): path is null or empty.");
|
||||
|
||||
var displayName = Path.GetFileName(path);
|
||||
if (string.IsNullOrWhiteSpace(displayName))
|
||||
displayName = path;
|
||||
|
||||
var stopAt = DateTime.UtcNow.AddMilliseconds(maxRetriesMs);
|
||||
Exception? lastEx = null;
|
||||
|
||||
while (DateTime.UtcNow < stopAt)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(path))
|
||||
{
|
||||
lastEx = new FileNotFoundException($"File not found after creation: {path}");
|
||||
Thread.Sleep(delayMs);
|
||||
continue;
|
||||
}
|
||||
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.Read))
|
||||
{
|
||||
// ensure we can open for read and write
|
||||
}
|
||||
|
||||
Log.Logger.Debug("Essential file validated: {DisplayName} at \"{Path}\"", displayName, path);
|
||||
return (true, null);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
lastEx = ex;
|
||||
Thread.Sleep(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
var msg = lastEx is not null
|
||||
? $"{displayName} could not be validated at \"{path}\": {lastEx.Message}"
|
||||
: $"{displayName} could not be validated at \"{path}\" (file not found or not accessible).";
|
||||
return (false, msg);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Validates that the file was created correctly and, if validation fails, reports the failure (log and optionally user).
|
||||
/// Equivalent to calling <see cref="ValidateCreated"/> then <see cref="ReportValidationFailure"/> when the result is not valid.
|
||||
/// </summary>
|
||||
/// <returns>True if the file is valid; false if validation failed (and failure has been reported).</returns>
|
||||
public static bool ValidateCreatedAndReport(
|
||||
string path,
|
||||
int maxRetriesMs = DefaultMaxRetriesMs,
|
||||
int delayMs = DefaultDelayMs)
|
||||
{
|
||||
var (success, errorMessage) = ValidateCreated(path, maxRetriesMs, delayMs);
|
||||
if (!success && errorMessage is not null)
|
||||
ReportValidationFailure(errorMessage);
|
||||
return success;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reports a validation failure: tries to log the error; if logging fails, invokes <see cref="ShowUserWhenLogUnavailable"/>.
|
||||
/// The message is prefixed with a strongly worded error notice for both log and user display.
|
||||
/// </summary>
|
||||
/// <param name="errorMessage">Message to log and optionally show to the user.</param>
|
||||
public static void ReportValidationFailure(string errorMessage)
|
||||
{
|
||||
var fullMessage = $"Critical error! Essential file validation failed: {errorMessage}";
|
||||
try
|
||||
{
|
||||
Log.Logger.Error("Critical error! Essential file validation failed: {ErrorMessage}. Call stack: {StackTrace}",
|
||||
errorMessage, Environment.StackTrace);
|
||||
}
|
||||
catch
|
||||
{
|
||||
ShowUserWhenLogUnavailable?.Invoke(fullMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -66,19 +66,30 @@ public class LibationFiles
|
||||
|
||||
/// <summary>
|
||||
/// Set the location of the Libation Files directory, updating appsettings.json.
|
||||
/// Never persists a relative path; always writes an absolute path so resolution is consistent on all platforms.
|
||||
/// </summary>
|
||||
public void SetLibationFiles(LongPath libationFilesDirectory)
|
||||
{
|
||||
var pathToPersist = libationFilesDirectory.Path;
|
||||
if (!string.IsNullOrWhiteSpace(pathToPersist) && !Path.IsPathRooted(pathToPersist))
|
||||
{
|
||||
var basePath = AppsettingsJsonFile is not null ? Path.GetDirectoryName(AppsettingsJsonFile) : null;
|
||||
pathToPersist = Path.GetFullPath(pathToPersist, !string.IsNullOrEmpty(basePath) ? basePath : Configuration.ProcessDirectory);
|
||||
}
|
||||
|
||||
if (AppsettingsJsonFile is null)
|
||||
{
|
||||
Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, libationFilesDirectory);
|
||||
Environment.SetEnvironmentVariable(LIBATION_FILES_DIR, pathToPersist);
|
||||
Location = pathToPersist;
|
||||
return;
|
||||
}
|
||||
|
||||
Location = pathToPersist;
|
||||
|
||||
var startingContents = File.ReadAllText(AppsettingsJsonFile);
|
||||
var jObj = JObject.Parse(startingContents);
|
||||
|
||||
jObj[LIBATION_FILES_KEY] = (string)(Location = libationFilesDirectory);
|
||||
jObj[LIBATION_FILES_KEY] = pathToPersist;
|
||||
|
||||
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
|
||||
if (startingContents == endingContents)
|
||||
@@ -88,11 +99,11 @@ public class LibationFiles
|
||||
{
|
||||
// now it's set in the file again but no settings have moved yet
|
||||
File.WriteAllText(AppsettingsJsonFile, endingContents);
|
||||
Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, libationFilesDirectory });
|
||||
Log.Logger.TryLogInformation("Libation files changed {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist });
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, libationFilesDirectory });
|
||||
Log.Logger.TryLogError(ex, "Failed to change Libation files location {@DebugInfo}", new { AppsettingsJsonFile, LIBATION_FILES_KEY, pathToPersist });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -122,6 +133,7 @@ public class LibationFiles
|
||||
try
|
||||
{
|
||||
File.WriteAllText(settingsFile, "{}");
|
||||
EssentialFileValidator.ValidateCreatedAndReport(settingsFile);
|
||||
}
|
||||
catch (Exception createEx)
|
||||
{
|
||||
@@ -210,6 +222,7 @@ public class LibationFiles
|
||||
{
|
||||
Directory.CreateDirectory(dir);
|
||||
File.WriteAllText(appsettingsFile, endingContents);
|
||||
EssentialFileValidator.ValidateCreatedAndReport(appsettingsFile);
|
||||
return appsettingsFile;
|
||||
}
|
||||
catch (Exception ex)
|
||||
@@ -248,6 +261,17 @@ public class LibationFiles
|
||||
libationFiles = runShellCommand("echo " + libationFiles) ?? libationFiles;
|
||||
}
|
||||
|
||||
// Resolve relative paths to absolute using the appsettings.json directory as base,
|
||||
// so that Location is consistent everywhere (e.g. avoids different resolution when
|
||||
// loading Serilog config vs. checking SettingsAreValid). Fixes Linux crash when
|
||||
// appsettings in process dir contains "./LibationFiles" (e.g. issue #1677).
|
||||
if (!string.IsNullOrWhiteSpace(libationFiles) && !Path.IsPathRooted(libationFiles))
|
||||
{
|
||||
var appSettingsDir = Path.GetDirectoryName(appsettingsPath.Path);
|
||||
var basePath = !string.IsNullOrEmpty(appSettingsDir) ? appSettingsDir : Configuration.ProcessDirectory;
|
||||
libationFiles = Path.GetFullPath(libationFiles, basePath);
|
||||
}
|
||||
|
||||
return libationFiles;
|
||||
|
||||
static string? runShellCommand(string command)
|
||||
|
||||
@@ -1,45 +1,39 @@
|
||||
using NameParser;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FileManager.NamingTemplate;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
public class ContributorDto : IFormattable
|
||||
public class ContributorDto(string name, string? audibleContributorId) : IFormattable
|
||||
{
|
||||
public HumanName HumanName { get; }
|
||||
public string? AudibleContributorId { get; }
|
||||
public ContributorDto(string name, string? audibleContributorId)
|
||||
private HumanName HumanName { get; } = new(RemoveSuffix(name), Prefer.FirstOverPrefix);
|
||||
private string? AudibleContributorId { get; } = audibleContributorId;
|
||||
|
||||
public static readonly Dictionary<string, Func<ContributorDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
HumanName = new HumanName(RemoveSuffix(name), Prefer.FirstOverPrefix);
|
||||
AudibleContributorId = audibleContributorId;
|
||||
}
|
||||
// Single-word names parse as first names. Use it as last name.
|
||||
{ "L", dto => string.IsNullOrWhiteSpace(dto.HumanName.Last) ? dto.HumanName.First : dto.HumanName.Last },
|
||||
// Because of the above, if we have only a first name, then we'd double the name as "FirstName FirstName", so clear the first name in that situation.
|
||||
{ "F", dto => string.IsNullOrWhiteSpace(dto.HumanName.Last) ? dto.HumanName.Last : dto.HumanName.First },
|
||||
|
||||
public override string ToString()
|
||||
=> ToString("{T} {F} {M} {L} {S}", null);
|
||||
{ "T", dto => dto.HumanName.Title },
|
||||
{ "M", dto => dto.HumanName.Middle },
|
||||
{ "S", dto => dto.HumanName.Suffix },
|
||||
{ "ID", dto => dto.AudibleContributorId },
|
||||
};
|
||||
|
||||
public string ToString(string? format, IFormatProvider? _)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(format))
|
||||
return ToString();
|
||||
public override string ToString() => ToString("{T} {F} {M} {L} {S}", null);
|
||||
|
||||
//Single-word names parse as first names. Use it as last name.
|
||||
var lastName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.First : HumanName.Last;
|
||||
//Because of the above, if the have only a first name, then we'd double the name as "FirstName FirstName", so clear the first name in that situation.
|
||||
var firstName = string.IsNullOrWhiteSpace(HumanName.Last) ? HumanName.Last : HumanName.First;
|
||||
|
||||
return format
|
||||
.Replace("{T}", HumanName.Title)
|
||||
.Replace("{F}", firstName)
|
||||
.Replace("{M}", HumanName.Middle)
|
||||
.Replace("{L}", lastName)
|
||||
.Replace("{S}", HumanName.Suffix)
|
||||
.Replace("{ID}", AudibleContributorId)
|
||||
.Trim();
|
||||
}
|
||||
public string ToString(string? format, IFormatProvider? provider)
|
||||
=> string.IsNullOrWhiteSpace(format)
|
||||
? ToString()
|
||||
: CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements);
|
||||
|
||||
private static string RemoveSuffix(string namesString)
|
||||
{
|
||||
namesString = namesString.Replace('’', '\'').Replace(" - Ret.", ", Ret.");
|
||||
int dashIndex = namesString.IndexOf(" - ");
|
||||
var dashIndex = namesString.IndexOf(" - ", StringComparison.Ordinal);
|
||||
return (dashIndex > 0 ? namesString[..dashIndex] : namesString).Trim();
|
||||
}
|
||||
}
|
||||
@@ -1,52 +1,101 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using static FileManager.NamingTemplate.RegExpExtensions;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
internal partial interface IListFormat<TList> where TList : IListFormat<TList>
|
||||
{
|
||||
static string Join<T>(string formatString, IEnumerable<T> items)
|
||||
where T : IFormattable
|
||||
static IEnumerable<T> FilteredList<T>(string formatString, IEnumerable<T> items)
|
||||
{
|
||||
var itemFormatter = Formatter(formatString);
|
||||
var separatorString = Separator(formatString) ?? ", ";
|
||||
var maxValues = Max(formatString) ?? items.Count();
|
||||
return Max(formatString, Slice(formatString, items));
|
||||
|
||||
var formattedValues = string.Join(separatorString, items.Take(maxValues).Select(n => n.ToString(itemFormatter, null)));
|
||||
|
||||
while (formattedValues.Contains(" "))
|
||||
formattedValues = formattedValues.Replace(" ", " ");
|
||||
|
||||
return formattedValues;
|
||||
|
||||
static string? Formatter(string formatString)
|
||||
static IEnumerable<T> Slice(string formatString, IEnumerable<T> items)
|
||||
{
|
||||
var formatMatch = TList.FormatRegex().Match(formatString);
|
||||
return formatMatch.Success ? formatMatch.Groups[1].Value : null;
|
||||
if (!SliceRegex().TryMatch(formatString, out var sliceMatch)) return items;
|
||||
|
||||
int.TryParse(sliceMatch.Groups["first"].ValueSpan, out var first);
|
||||
int.TryParse(sliceMatch.Groups["last"].ValueSpan, out var last);
|
||||
if (!sliceMatch.Groups["op"].Success) last = first;
|
||||
|
||||
if (last > 0)
|
||||
{
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
|
||||
// strange constellation which might not work as intended: slice(-2..3) needs at least 4 items to return anything
|
||||
// to get this working, we need to adjust the start-pointer based on the total count of items
|
||||
if (first < 0)
|
||||
first += items.Count() + 1;
|
||||
items = items.Take(last);
|
||||
// ReSharper restore PossibleMultipleEnumeration
|
||||
}
|
||||
|
||||
if (first > 1) items = items.Skip(first - 1);
|
||||
else if (first < 0) items = items.TakeLast(-first);
|
||||
|
||||
if (last < -1) items = items.SkipLast(-last - 1);
|
||||
|
||||
return items;
|
||||
}
|
||||
|
||||
static int? Max(string formatString)
|
||||
static IEnumerable<T> Max(string formatString, IEnumerable<T> items)
|
||||
{
|
||||
var maxMatch = MaxRegex().Match(formatString);
|
||||
return maxMatch.Success && int.TryParse(maxMatch.Groups[1].Value, out var max) ? int.Max(1, max) : null;
|
||||
}
|
||||
|
||||
static string? Separator(string formatString)
|
||||
{
|
||||
var separatorMatch = SeparatorRegex().Match(formatString);
|
||||
return separatorMatch.Success ? separatorMatch.Groups[1].Value : ", ";
|
||||
return MaxRegex().Match(formatString).TryParseInt("max", out var max)
|
||||
? items.Take(max)
|
||||
: items;
|
||||
}
|
||||
}
|
||||
|
||||
static IEnumerable<string> FormattedList<T>(string? formatString, IEnumerable<T> items, CultureInfo? culture) where T : IFormattable
|
||||
{
|
||||
if (formatString is null) return items.Select(n => n.ToString(null, culture));
|
||||
var format = TList.FormatRegex().Match(formatString).ResolveValue("format");
|
||||
var separator = SeparatorRegex().Match(formatString).ResolveValue("separator");
|
||||
var formattedItems = FilteredList(formatString, items).Select(ItemFormatter);
|
||||
|
||||
if (separator is null) return formattedItems;
|
||||
var joined = Join(separator, formattedItems);
|
||||
return joined is null ? [] : [joined];
|
||||
|
||||
string ItemFormatter(T n) => n.ToString(format, culture);
|
||||
}
|
||||
|
||||
static string? Join(IEnumerable<string>? formattedItems, CultureInfo? culture)
|
||||
{
|
||||
return formattedItems is null ? null : Join(", ", formattedItems);
|
||||
}
|
||||
|
||||
private static string? Join(string separator, IEnumerable<string> strings)
|
||||
{
|
||||
// ReSharper disable PossibleMultipleEnumeration
|
||||
return strings.Any()
|
||||
? CollapseSpacesAndTrimRegex().Replace(string.Join(separator, strings), "")
|
||||
: null;
|
||||
// ReSharper restore PossibleMultipleEnumeration
|
||||
}
|
||||
|
||||
// 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();
|
||||
|
||||
static abstract Regex FormatRegex();
|
||||
|
||||
/// <summary> Separator can be anything </summary>
|
||||
[GeneratedRegex(@"[Ss]eparator\((.*?)\)")]
|
||||
private static partial Regex SeparatorRegex();
|
||||
/// <summary>
|
||||
/// Slice can be a single number or a range like "start..end".
|
||||
/// Leaving out one value of a range, it will start on the first or end on the last respectively.
|
||||
/// Negative numbers will start counting from the end with "-1" being the last element.
|
||||
/// </summary>
|
||||
[GeneratedRegex(@"[Ss]lice\(\s*(?<first>-?[1-9]\d*)?\s*(?:(?<op>\.\.\.*)\s*(?<last>-?[1-9]\d*)?(?(first)|(?<=\d))\s*)?\)")]
|
||||
private static partial Regex SliceRegex();
|
||||
|
||||
/// <summary> Max must have a 1 or 2-digit number </summary>
|
||||
[GeneratedRegex(@"[Mm]ax\(\s*?(\d{1,2})\s*?\)")]
|
||||
[GeneratedRegex(@"[Mm]ax\(\s*(?<max>[1-9]\d?)\s*\)")]
|
||||
private static partial Regex MaxRegex();
|
||||
|
||||
/// <summary> Separator can be anything </summary>
|
||||
[GeneratedRegex(@"[Ss]eparator\((?<separator>.*?)\)")]
|
||||
private static partial Regex SeparatorRegex();
|
||||
}
|
||||
@@ -22,10 +22,12 @@ public class BookDto
|
||||
public IEnumerable<SeriesDto>? Series { get; set; }
|
||||
public SeriesDto? FirstSeries => Series?.FirstOrDefault();
|
||||
|
||||
public bool IsAbridged { get; set; }
|
||||
public bool IsSeries => Series is not null;
|
||||
public bool IsPodcastParent { get; set; }
|
||||
public bool IsPodcast { get; set; }
|
||||
|
||||
public TimeSpan LengthInMinutes { get; set; }
|
||||
public int? BitRate { get; set; }
|
||||
public int? SampleRate { get; set; }
|
||||
public int? Channels { get; set; }
|
||||
@@ -42,4 +44,6 @@ public class LibraryBookDto : BookDto
|
||||
public DateTime? DateAdded { get; set; }
|
||||
public string? Account { get; set; }
|
||||
public string? AccountNickname { get; set; }
|
||||
public IEnumerable<StringDto>? Tags { get; set; }
|
||||
public string? FirstTag => Tags?.FirstOrDefault()?.Value;
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using FileManager.NamingTemplate;
|
||||
using System;
|
||||
using FileManager.NamingTemplate;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
@@ -7,26 +9,50 @@ namespace LibationFileManager.Templates;
|
||||
|
||||
internal partial class NameListFormat : IListFormat<NameListFormat>
|
||||
{
|
||||
public static string Formatter(ITemplateTag _, IEnumerable<ContributorDto>? names, string formatString)
|
||||
=> names is null ? string.Empty
|
||||
: IListFormat<NameListFormat>.Join(formatString, Sort(names, formatString));
|
||||
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<ContributorDto>? names, string? formatString, CultureInfo? culture)
|
||||
=> names is null
|
||||
? []
|
||||
: IListFormat<NameListFormat>.FormattedList(formatString, Sort(names, formatString, ContributorDto.FormatReplacements), culture);
|
||||
|
||||
private static IEnumerable<ContributorDto> Sort(IEnumerable<ContributorDto> names, string formatString)
|
||||
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? names, CultureInfo? culture)
|
||||
=> IListFormat<NameListFormat>.Join(names, culture);
|
||||
|
||||
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> formatReplacements)
|
||||
{
|
||||
var sortMatch = SortRegex().Match(formatString);
|
||||
return
|
||||
sortMatch.Success
|
||||
? sortMatch.Groups[1].Value == "F" ? names.OrderBy(n => n.HumanName.First)
|
||||
: sortMatch.Groups[1].Value == "M" ? names.OrderBy(n => n.HumanName.Middle)
|
||||
: sortMatch.Groups[1].Value == "L" ? names.OrderBy(n => n.HumanName.Last)
|
||||
: names
|
||||
: names;
|
||||
var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern");
|
||||
if (pattern is null) return entries;
|
||||
|
||||
IOrderedEnumerable<T>? ordered = null;
|
||||
foreach (Match m in SortTokenizer().Matches(pattern))
|
||||
{
|
||||
// Dictionary is case-insensitive, no ToUpper needed
|
||||
if (!formatReplacements.TryGetValue(m.Groups["token"].Value, out var selector))
|
||||
continue;
|
||||
|
||||
ordered = m.Groups["descending"].Success
|
||||
? ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderByDescending(selector)
|
||||
: ordered.ThenByDescending(selector)
|
||||
: ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderBy(selector)
|
||||
: ordered.ThenBy(selector);
|
||||
}
|
||||
|
||||
return ordered ?? entries;
|
||||
}
|
||||
|
||||
/// <summary> Sort must have exactly one of the characters F, M, or L </summary>
|
||||
[GeneratedRegex(@"[Ss]ort\(\s*?([FML])\s*?\)")]
|
||||
private const string Token = @"(?:[TFMLS]|ID)";
|
||||
|
||||
/// <summary> Sort must have at least one of the token labels T, F, M, L, S or ID. Use lower case for descending direction and add multiple tokens to sort by multiple fields. Spaces may be used to separate tokens.</summary>
|
||||
[GeneratedRegex($@"[Ss]ort\(\s*(?i:(?<pattern>(?:{Token}\s*?)+))\s*\)")]
|
||||
private static partial Regex SortRegex();
|
||||
/// <summary> Format must have at least one of the string {T}, {F}, {M}, {L}, {S}, or {ID} </summary>
|
||||
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{[TFMLS]}|{ID})+.*?)\)")]
|
||||
|
||||
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SortTokenizer();
|
||||
|
||||
/// <summary> Format must have at least one of the strings {T}, {F}, {M}, {L}, {S}, or {ID} (optionally with formatting like {L:u})</summary>
|
||||
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
|
||||
public static partial Regex FormatRegex();
|
||||
}
|
||||
@@ -1,33 +1,24 @@
|
||||
using System;
|
||||
using System.Text.RegularExpressions;
|
||||
using System.Collections.Generic;
|
||||
using FileManager.NamingTemplate;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
public partial record SeriesDto : IFormattable
|
||||
public record SeriesDto(string? Name, string? Number, string AudibleSeriesId) : IFormattable
|
||||
{
|
||||
public string? Name { get; }
|
||||
public SeriesOrder Order { get; } = SeriesOrder.Parse(Number);
|
||||
|
||||
public SeriesOrder Order { get; }
|
||||
public string AudibleSeriesId { get; }
|
||||
public SeriesDto(string? name, string? number, string audibleSeriesId)
|
||||
public static readonly Dictionary<string, Func<SeriesDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
Name = name;
|
||||
Order = SeriesOrder.Parse(number);
|
||||
AudibleSeriesId = audibleSeriesId;
|
||||
}
|
||||
{ "#", dto => dto.Order },
|
||||
{ "N", dto => dto.Name },
|
||||
{ "ID", dto => dto.AudibleSeriesId }
|
||||
};
|
||||
|
||||
public override string? ToString() => Name?.Trim();
|
||||
public string ToString(string? format, IFormatProvider? _)
|
||||
=> string.IsNullOrWhiteSpace(format) ? ToString() ?? string.Empty
|
||||
: FormatRegex().Replace(format, MatchEvaluator)
|
||||
.Replace("{N}", Name)
|
||||
.Replace("{ID}", AudibleSeriesId)
|
||||
.Trim();
|
||||
|
||||
private string MatchEvaluator(Match match)
|
||||
=> Order?.ToString(match.Groups[1].Value, null) ?? "";
|
||||
|
||||
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
|
||||
[GeneratedRegex(@"{#(?:\:(.*?))?}")]
|
||||
public static partial Regex FormatRegex();
|
||||
public string ToString(string? format, IFormatProvider? provider)
|
||||
=> string.IsNullOrWhiteSpace(format)
|
||||
? ToString() ?? string.Empty
|
||||
: CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements);
|
||||
}
|
||||
@@ -1,16 +1,58 @@
|
||||
using FileManager.NamingTemplate;
|
||||
using System;
|
||||
using FileManager.NamingTemplate;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
internal partial class SeriesListFormat : IListFormat<SeriesListFormat>
|
||||
{
|
||||
public static string Formatter(ITemplateTag _, IEnumerable<SeriesDto>? series, string formatString)
|
||||
=> series is null ? string.Empty
|
||||
: IListFormat<SeriesListFormat>.Join(formatString, series);
|
||||
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<SeriesDto>? series, string? formatString, CultureInfo? culture)
|
||||
=> series is null
|
||||
? []
|
||||
: IListFormat<SeriesListFormat>.FormattedList(formatString, Sort(series, formatString, SeriesDto.FormatReplacements), culture);
|
||||
|
||||
/// <summary> Format must have at least one of the string {N}, {#}, {ID} </summary>
|
||||
[GeneratedRegex(@"[Ff]ormat\((.*?(?:{#(?:\:.*?)?}|{N}|{ID})+.*?)\)")]
|
||||
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? series, CultureInfo? culture)
|
||||
=> IListFormat<NameListFormat>.Join(series, culture);
|
||||
|
||||
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> formatReplacements)
|
||||
{
|
||||
var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern");
|
||||
if (pattern is null) return entries;
|
||||
|
||||
IOrderedEnumerable<T>? ordered = null;
|
||||
foreach (Match m in SortTokenizer().Matches(pattern))
|
||||
{
|
||||
// Dictionary is case-insensitive, no ToUpper needed
|
||||
if (!formatReplacements.TryGetValue(m.Groups["token"].Value, out var selector))
|
||||
continue;
|
||||
|
||||
ordered = m.Groups["descending"].Success
|
||||
? ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderByDescending(selector)
|
||||
: ordered.ThenByDescending(selector)
|
||||
: ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderBy(selector)
|
||||
: ordered.ThenBy(selector);
|
||||
}
|
||||
|
||||
return ordered ?? entries;
|
||||
}
|
||||
|
||||
private const string Token = @"(?:[#N]|ID)";
|
||||
|
||||
/// <summary> Sort must have at least one of the token labels N, # or ID. Use lower case for descending direction and add multiple tokens to sort by multiple fields. Spaces may be used to separate tokens.</summary>
|
||||
[GeneratedRegex($@"[Ss]ort\(\s*(?i:(?<pattern>(?:{Token}\s*?)+))\s*\)")]
|
||||
private static partial Regex SortRegex();
|
||||
|
||||
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SortTokenizer();
|
||||
|
||||
/// <summary> Format must have at least one of the strings {N}, {#}, {ID} (optionally with formatting like {N:u})</summary>
|
||||
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
|
||||
public static partial Regex FormatRegex();
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics.CodeAnalysis;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
public class SeriesOrder : IFormattable
|
||||
{
|
||||
public object[] OrderParts { get; }
|
||||
private object[] OrderParts { get; }
|
||||
private SeriesOrder(object[] orderParts)
|
||||
{
|
||||
OrderParts = orderParts;
|
||||
@@ -19,11 +20,16 @@ public class SeriesOrder : IFormattable
|
||||
/// Use float formatters to format the number parts of the order.
|
||||
/// </summary>
|
||||
public string ToString(string? format, IFormatProvider? formatProvider)
|
||||
=> string.Concat(OrderParts.Select(p => p is float f ? f.ToString(format) : p.ToString())).Trim();
|
||||
=> string.Concat(OrderParts.Select(p => p switch
|
||||
{
|
||||
float f => f.ToString(format, formatProvider ?? CultureInfo.InvariantCulture),
|
||||
IFormattable f => f.ToString(format, formatProvider),
|
||||
_ => p.ToString(),
|
||||
})).Trim();
|
||||
|
||||
public static SeriesOrder Parse(string? order)
|
||||
{
|
||||
List<object> parts = new();
|
||||
List<object> parts = [];
|
||||
while (TryParseNumber(order, out var value, out var range))
|
||||
{
|
||||
var prefix = order[..range.Start.Value];
|
||||
@@ -57,7 +63,7 @@ public class SeriesOrder : IFormattable
|
||||
return false;
|
||||
}
|
||||
|
||||
for (int s = 0; s < numString.Length; s++)
|
||||
for (var s = 0; s < numString.Length; s++)
|
||||
{
|
||||
//Assume any valid number will begin with a digit.
|
||||
//This way, leading dots and dashes will never be considered part of a number, so
|
||||
@@ -65,7 +71,7 @@ public class SeriesOrder : IFormattable
|
||||
if (!char.IsDigit(numString[s]))
|
||||
continue;
|
||||
|
||||
for (int e = numString.Length; e > s; e--)
|
||||
for (var e = numString.Length; e > s; e--)
|
||||
{
|
||||
//The float parser will succeed with trailing whitespace,
|
||||
//but we want to preserve it in the final display string.
|
||||
@@ -73,7 +79,7 @@ public class SeriesOrder : IFormattable
|
||||
continue;
|
||||
|
||||
var substring = numString[s..e];
|
||||
if (float.TryParse(substring, System.Globalization.CultureInfo.InvariantCulture, out value))
|
||||
if (float.TryParse(substring, CultureInfo.InvariantCulture, out value))
|
||||
{
|
||||
range = new Range(s, e);
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using FileManager.NamingTemplate;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
public record StringDto(string Value) : IFormattable
|
||||
{
|
||||
public static readonly Dictionary<string, Func<StringDto, object?>> FormatReplacements = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
{ "S", dto => dto.Value }
|
||||
};
|
||||
|
||||
public override string ToString() => Value;
|
||||
|
||||
public string ToString(string? format, IFormatProvider? provider)
|
||||
=> string.IsNullOrWhiteSpace(format)
|
||||
? ToString()
|
||||
: CommonFormatters.TemplateStringFormatter(this, format, provider, FormatReplacements);
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
using System;
|
||||
using FileManager.NamingTemplate;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
internal partial class StringListFormat : IListFormat<StringListFormat>
|
||||
{
|
||||
public static IEnumerable<string> Formatter(ITemplateTag _, IEnumerable<StringDto>? entries, string? formatString, CultureInfo? culture)
|
||||
=> entries is null
|
||||
? []
|
||||
: IListFormat<StringListFormat>.FormattedList(formatString, Sort(entries, formatString, StringDto.FormatReplacements), culture);
|
||||
|
||||
public static string? Finalizer(ITemplateTag _, IEnumerable<string>? entries, CultureInfo? culture)
|
||||
=> IListFormat<StringListFormat>.Join(entries, culture);
|
||||
|
||||
private static IEnumerable<T> Sort<T>(IEnumerable<T> entries, string? formatString, Dictionary<string, Func<T, object?>> formatReplacements)
|
||||
{
|
||||
var pattern = formatString is null ? null : SortRegex().Match(formatString).ResolveValue("pattern");
|
||||
if (pattern is null) return entries;
|
||||
|
||||
IOrderedEnumerable<T>? ordered = null;
|
||||
foreach (Match m in SortTokenizer().Matches(pattern))
|
||||
{
|
||||
// Dictionary is case-insensitive, no ToUpper needed
|
||||
if (!formatReplacements.TryGetValue(m.Groups["token"].Value, out var selector))
|
||||
continue;
|
||||
|
||||
ordered = m.Groups["descending"].Success
|
||||
? ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderByDescending(selector)
|
||||
: ordered.ThenByDescending(selector)
|
||||
: ordered is null
|
||||
// ReSharper disable once PossibleMultipleEnumeration
|
||||
? entries.OrderBy(selector)
|
||||
: ordered.ThenBy(selector);
|
||||
}
|
||||
|
||||
return ordered ?? entries;
|
||||
}
|
||||
|
||||
private const string Token = "S";
|
||||
|
||||
/// <summary> Sort must have the token label S. Use lower case for descending direction.</summary>
|
||||
[GeneratedRegex($@"[Ss]ort\(\s*(?i:(?<pattern>(?:{Token}\s*?)+))\s*\)")]
|
||||
private static partial Regex SortRegex();
|
||||
|
||||
[GeneratedRegex($@"\G(?<token>{Token})(?<descending>(?-i:(?<=\G\P{{Lu}}+)))?\s*", RegexOptions.IgnoreCase)]
|
||||
private static partial Regex SortTokenizer();
|
||||
|
||||
/// <summary> Format must have the string {S} (optionally with formatting like {S:u})</summary>
|
||||
[GeneratedRegex($@"[Ff]ormat\((?<format>.*?\{{{Token}(?::.*?)?\}}.*?)\)")]
|
||||
public static partial Regex FormatRegex();
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace LibationFileManager.Templates;
|
||||
|
||||
public sealed class TemplateTags : ITemplateTag
|
||||
{
|
||||
public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
|
||||
public string TagName { get; }
|
||||
public string DefaultValue { get; }
|
||||
public string Description { get; }
|
||||
@@ -18,42 +17,53 @@ public sealed class TemplateTags : ITemplateTag
|
||||
Display = display ?? $"<{tagName}>";
|
||||
}
|
||||
|
||||
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters");
|
||||
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
|
||||
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
|
||||
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
|
||||
public static TemplateTags ChCount { get; } = new("ch count", "Number of chapters");
|
||||
public static TemplateTags ChTitle { get; } = new("ch title", "Chapter title");
|
||||
public static TemplateTags ChNumber { get; } = new("ch#", "Chapter #");
|
||||
public static TemplateTags ChNumber0 { get; } = new("ch# 0", "Chapter # with leading zeros");
|
||||
|
||||
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
|
||||
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title with subtitle");
|
||||
public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon");
|
||||
public static TemplateTags AudibleTitle { get; } = new TemplateTags("audible title", "Audible's title (does not include subtitle)");
|
||||
public static TemplateTags AudibleSubtitle { get; } = new TemplateTags("audible subtitle", "Audible's subtitle");
|
||||
public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)");
|
||||
public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author");
|
||||
public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)");
|
||||
public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator");
|
||||
public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
|
||||
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
|
||||
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
|
||||
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook");
|
||||
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook");
|
||||
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook");
|
||||
public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook");
|
||||
public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook");
|
||||
public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook");
|
||||
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
|
||||
public static TemplateTags Id { get; } = new("id", "Audible ID");
|
||||
public static TemplateTags Title { get; } = new("title", "Full title with subtitle");
|
||||
public static TemplateTags TitleShort { get; } = new("title short", "Title. Stop at first colon");
|
||||
public static TemplateTags AudibleTitle { get; } = new("audible title", "Audible's title (does not include subtitle)");
|
||||
public static TemplateTags AudibleSubtitle { get; } = new("audible subtitle", "Audible's subtitle");
|
||||
public static TemplateTags Author { get; } = new("author", "Author(s)");
|
||||
public static TemplateTags FirstAuthor { get; } = new("first author", "First author");
|
||||
public static TemplateTags Narrator { get; } = new("narrator", "Narrator(s)");
|
||||
public static TemplateTags FirstNarrator { get; } = new("first narrator", "First narrator");
|
||||
public static TemplateTags Series { get; } = new("series", "All series to which the book belongs (if any)");
|
||||
public static TemplateTags FirstSeries { get; } = new("first series", "First series");
|
||||
public static TemplateTags SeriesNumber { get; } = new("series#", "Number order in series (alias for <first series[{#}]>");
|
||||
public static TemplateTags Minutes { get; } = new("minutes", "Length in minutes");
|
||||
public static TemplateTags Bitrate { get; } = new("bitrate", "Bitrate (kbps) of the last downloaded audiobook");
|
||||
public static TemplateTags SampleRate { get; } = new("samplerate", "Sample rate (Hz) of the last downloaded audiobook");
|
||||
public static TemplateTags Channels { get; } = new("channels", "Number of audio channels in the last downloaded audiobook");
|
||||
public static TemplateTags Codec { get; } = new("codec", "Audio codec of the last downloaded audiobook");
|
||||
public static TemplateTags FileVersion { get; } = new("file version", "Audible's file version number of the last downloaded audiobook");
|
||||
public static TemplateTags LibationVersion { get; } = new("libation version", "Libation version used during last download of the audiobook");
|
||||
public static TemplateTags Account { get; } = new("account", "Audible account of this book");
|
||||
public static TemplateTags AccountNickname { get; } = new("account nickname", "Audible account nickname of this book");
|
||||
public static TemplateTags Tag { get; } = new("tag", "Tag(s)");
|
||||
public static TemplateTags FirstTag { get; } = new("first tag", "First tag");
|
||||
public static TemplateTags Locale { get; } = new("locale", "Region/country");
|
||||
public static TemplateTags YearPublished { get; } = new("year", "Year published");
|
||||
public static TemplateTags Language { get; } = new("language", "Book's language");
|
||||
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
|
||||
|
||||
public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>");
|
||||
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>");
|
||||
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
|
||||
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
|
||||
public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<if podcastparent-><-if podcastparent>", "<if podcastparent->...<-if podcastparent>");
|
||||
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
|
||||
public static TemplateTags Has { get; } = new TemplateTags("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<has -><-has>", "<has PROPERTY->...<-has>");
|
||||
public static TemplateTags FileDate { get; } = new("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{CommonFormatters.DefaultDateFormat}]>", "<file date [...]>");
|
||||
public static TemplateTags DatePublished { get; } = new("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{CommonFormatters.DefaultDateFormat}]>", "<pub date [...]>");
|
||||
|
||||
public static TemplateTags DateAdded { get; } =
|
||||
new("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{CommonFormatters.DefaultDateFormat}]>", "<date added [...]>");
|
||||
|
||||
public static TemplateTags IfSeries { get; } = new("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
|
||||
public static TemplateTags IfPodcast { get; } = new("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
|
||||
|
||||
public static TemplateTags IfPodcastParent { get; } = new("if podcastparent", "Only include if item is a podcast series parent", "<if podcastparent-><-if podcastparent>",
|
||||
"<if podcastparent->...<-if podcastparent>");
|
||||
|
||||
public static TemplateTags IfBookseries { get; } = new("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
|
||||
public static TemplateTags IfAbridged { get; } = new("if abridged", "Only include if abridged", "<if abridged-><-if abridged>", "<if abridged->...<-if abridged>");
|
||||
public static TemplateTags Has { get; } = new("has", "Only include if PROPERTY has a value (i.e. not null or empty)", "<has -><-has>", "<has PROPERTY->...<-has>");
|
||||
public static TemplateTags Is { get; } = new("is", "Only include if PROPERTY has a value satisfying the check (i.e. string comparison)", "<is -><-is>", "<is PROPERTY->...<-is>");
|
||||
}
|
||||
@@ -1,12 +1,13 @@
|
||||
using AaxDecrypter;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AaxDecrypter;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using FileManager.NamingTemplate;
|
||||
using NameParser;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationFileManager.Templates;
|
||||
|
||||
@@ -20,8 +21,8 @@ public interface ITemplate
|
||||
|
||||
public abstract class Templates
|
||||
{
|
||||
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
|
||||
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
|
||||
public const string ErrorFullPathIsInvalid = @"No colons or full paths allowed. Eg: should not start with C:\";
|
||||
public const string WarningNoChapterNumberTag = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
|
||||
|
||||
//Assigning the properties in the static constructor will require all
|
||||
//Templates users to have a valid configuration file. To allow tests
|
||||
@@ -44,7 +45,7 @@ public abstract class Templates
|
||||
public static bool TryGetTemplate<T>(string? templateText, out T template) where T : Templates, ITemplate, new()
|
||||
{
|
||||
var namingTemplate = NamingTemplate.Parse(templateText, T.TagCollections);
|
||||
template = new() { NamingTemplate = namingTemplate };
|
||||
template = new T() { NamingTemplate = namingTemplate };
|
||||
return !namingTemplate.Errors.Any();
|
||||
}
|
||||
|
||||
@@ -77,11 +78,9 @@ public abstract class Templates
|
||||
|
||||
#region Template Properties
|
||||
|
||||
public IEnumerable<TemplateTags> TagsRegistered
|
||||
=> NamingTemplate?.TagsRegistered.Cast<TemplateTags>() ?? Enumerable.Empty<TemplateTags>();
|
||||
public IEnumerable<TemplateTags> TagsInUse
|
||||
=> NamingTemplate?.TagsInUse.Cast<TemplateTags>() ?? Enumerable.Empty<TemplateTags>();
|
||||
public string TemplateText => NamingTemplate?.TemplateText ?? "";
|
||||
public IEnumerable<TemplateTags> TagsRegistered => NamingTemplate.TagsRegistered.Cast<TemplateTags>();
|
||||
public IEnumerable<TemplateTags> TagsInUse => NamingTemplate.TagsInUse.Cast<TemplateTags>();
|
||||
public string TemplateText => NamingTemplate.TemplateText;
|
||||
|
||||
protected NamingTemplate NamingTemplate
|
||||
{
|
||||
@@ -104,23 +103,33 @@ public abstract class Templates
|
||||
#region to file name
|
||||
|
||||
public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps)
|
||||
=> GetName(libraryBookDto, multiChapProps, null);
|
||||
|
||||
public string GetName(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, CultureInfo? culture)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
|
||||
return string.Concat(NamingTemplate.Evaluate(libraryBookDto, multiChapProps, new CombinedDto(libraryBookDto, multiChapProps)).Select(p => p.Value));
|
||||
return string.Concat(NamingTemplate.Evaluate(culture, libraryBookDto, multiChapProps, new CombinedDto(libraryBookDto, multiChapProps)).Select(p => p.Value));
|
||||
}
|
||||
|
||||
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
|
||||
=> GetFilename(libraryBookDto, baseDir, fileExtension, culture: null, replacements: replacements, returnFirstExisting: returnFirstExisting);
|
||||
|
||||
public LongPath GetFilename(LibraryBookDto libraryBookDto, string baseDir, string fileExtension, CultureInfo? culture, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
ArgumentValidator.EnsureNotNull(baseDir, nameof(baseDir));
|
||||
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
|
||||
|
||||
replacements ??= Configuration.Instance.ReplacementCharacters;
|
||||
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto);
|
||||
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, culture);
|
||||
}
|
||||
|
||||
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
|
||||
=> GetFilename(libraryBookDto, multiChapProps, baseDir, fileExtension, culture: null, replacements: replacements, returnFirstExisting: returnFirstExisting);
|
||||
|
||||
public LongPath GetFilename(LibraryBookDto libraryBookDto, MultiConvertFileProperties multiChapProps, string baseDir, string fileExtension, CultureInfo? culture,
|
||||
ReplacementCharacters? replacements = null, bool returnFirstExisting = false)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
ArgumentValidator.EnsureNotNull(multiChapProps, nameof(multiChapProps));
|
||||
@@ -128,22 +137,23 @@ public abstract class Templates
|
||||
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
|
||||
|
||||
replacements ??= Configuration.Instance.ReplacementCharacters;
|
||||
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, multiChapProps);
|
||||
return GetFilename(baseDir, fileExtension, replacements, returnFirstExisting, libraryBookDto, culture, multiChapProps);
|
||||
}
|
||||
|
||||
protected virtual IEnumerable<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
|
||||
=> parts.Select(p => replacements.ReplaceFilenameChars(p.Value));
|
||||
|
||||
private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, LibraryBookDto lbDto, MultiConvertFileProperties? multiDto = null)
|
||||
private LongPath GetFilename(string baseDir, string fileExtension, ReplacementCharacters replacements, bool returnFirstExisting, LibraryBookDto lbDto, CultureInfo? culture,
|
||||
MultiConvertFileProperties? multiDto = null)
|
||||
{
|
||||
fileExtension = FileUtility.GetStandardizedExtension(fileExtension);
|
||||
|
||||
var parts = NamingTemplate.Evaluate(lbDto, multiDto, new CombinedDto(lbDto, multiDto)).ToList();
|
||||
var parts = NamingTemplate.Evaluate(culture, lbDto, multiDto, new CombinedDto(lbDto, multiDto)).ToList();
|
||||
var pathParts = GetPathParts(GetTemplatePartsStrings(parts, replacements));
|
||||
|
||||
//Remove 1 character from the end of the longest filename part until
|
||||
//the total filename is less than max filename length
|
||||
for (int i = 0; i < pathParts.Count; i++)
|
||||
for (var i = 0; i < pathParts.Count; i++)
|
||||
{
|
||||
var part = pathParts[i];
|
||||
|
||||
@@ -155,7 +165,7 @@ public abstract class Templates
|
||||
|
||||
while (part.Sum(GetFilenameLength) > maxFilenameLength)
|
||||
{
|
||||
int maxLength = part.Max(p => p.Length);
|
||||
var maxLength = part.Max(p => p.Length);
|
||||
var maxEntry = part.First(p => p.Length == maxLength);
|
||||
|
||||
var maxIndex = part.IndexOf(maxEntry);
|
||||
@@ -180,8 +190,8 @@ public abstract class Templates
|
||||
/// <returns>A List of template directories. Each directory is a list of template part strings</returns>
|
||||
private static List<List<string>> GetPathParts(IEnumerable<string> templateParts)
|
||||
{
|
||||
List<List<string>> directories = new();
|
||||
List<string> dir = new();
|
||||
List<List<string>> directories = [];
|
||||
List<string> dir = [];
|
||||
|
||||
foreach (var part in templateParts)
|
||||
{
|
||||
@@ -191,7 +201,7 @@ public abstract class Templates
|
||||
dir.Add(part[lastIndex..slashIndex]);
|
||||
RemoveSpaces(dir);
|
||||
directories.Add(dir);
|
||||
dir = new();
|
||||
dir = [];
|
||||
|
||||
lastIndex = slashIndex + 1;
|
||||
}
|
||||
@@ -222,7 +232,7 @@ public abstract class Templates
|
||||
parts[^1] = parts[^1].TrimEnd();
|
||||
|
||||
//Replace all multispace substrings with single space
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
for (var i = 0; i < parts.Count; i++)
|
||||
{
|
||||
string original;
|
||||
do
|
||||
@@ -233,11 +243,11 @@ public abstract class Templates
|
||||
}
|
||||
|
||||
//Remove instances of double spaces at part boundaries
|
||||
for (int i = 1; i < parts.Count; i++)
|
||||
for (var i = 1; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i - 1].EndsWith(' ') && parts[i].StartsWith(' '))
|
||||
{
|
||||
parts[i] = parts[i].Substring(1);
|
||||
parts[i] = parts[i][1..];
|
||||
|
||||
if (parts[i].Length == 0)
|
||||
{
|
||||
@@ -253,24 +263,24 @@ public abstract class Templates
|
||||
#region Registered Template Properties
|
||||
|
||||
private static readonly PropertyTagCollection<LibraryBookDto> filePropertyTags =
|
||||
new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter)
|
||||
new(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.DateTimeFormatter, CommonFormatters.IntegerFormatter, CommonFormatters.FloatFormatter)
|
||||
{
|
||||
//Don't allow formatting of Id
|
||||
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v ?? "" },
|
||||
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v },
|
||||
{ TemplateTags.Title, lb => lb.TitleWithSubtitle },
|
||||
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
|
||||
{ TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) },
|
||||
{ TemplateTags.AudibleTitle, lb => lb.Title },
|
||||
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
|
||||
{ TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter },
|
||||
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor, FormattableFormatter },
|
||||
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter },
|
||||
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator, FormattableFormatter },
|
||||
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter },
|
||||
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter },
|
||||
{ TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, FormattableFormatter },
|
||||
{ TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter, NameListFormat.Finalizer },
|
||||
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor, CommonFormatters.FormattableFormatter },
|
||||
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter, NameListFormat.Finalizer },
|
||||
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator, CommonFormatters.FormattableFormatter },
|
||||
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer },
|
||||
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter },
|
||||
{ TemplateTags.SeriesNumber, lb => lb.FirstSeries?.Order, CommonFormatters.FormattableFormatter },
|
||||
{ TemplateTags.Language, lb => lb.Language },
|
||||
//Don't allow formatting of LanguageShort
|
||||
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
|
||||
{ TemplateTags.LanguageShort, lb => lb.Language, CommonFormatters.LanguageShortFormatter },
|
||||
{ TemplateTags.Account, lb => lb.Account },
|
||||
{ TemplateTags.AccountNickname, lb => lb.AccountNickname },
|
||||
{ TemplateTags.Locale, lb => lb.Locale },
|
||||
@@ -278,11 +288,14 @@ public abstract class Templates
|
||||
{ TemplateTags.DatePublished, lb => lb.DatePublished },
|
||||
{ TemplateTags.DateAdded, lb => lb.DateAdded },
|
||||
{ TemplateTags.FileDate, lb => lb.FileDate },
|
||||
{ TemplateTags.Tag, lb => lb.Tags, StringListFormat.Formatter, StringListFormat.Finalizer },
|
||||
{ TemplateTags.FirstTag, lb => lb.FirstTag },
|
||||
};
|
||||
|
||||
private static readonly PropertyTagCollection<LibraryBookDto> audioFilePropertyTags =
|
||||
new(caseSensative: true, StringFormatter, IntegerFormatter)
|
||||
new(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter)
|
||||
{
|
||||
{ TemplateTags.Minutes, lb => lb.LengthInMinutes, CommonFormatters.MinutesFormatter },
|
||||
{ TemplateTags.Bitrate, lb => lb.BitRate },
|
||||
{ TemplateTags.SampleRate, lb => lb.SampleRate },
|
||||
{ TemplateTags.Channels, lb => lb.Channels },
|
||||
@@ -291,18 +304,18 @@ public abstract class Templates
|
||||
{ TemplateTags.LibationVersion, lb => lb.LibationVersion },
|
||||
};
|
||||
|
||||
private static readonly List<TagCollection> chapterPropertyTags = new()
|
||||
{
|
||||
new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter)
|
||||
private static readonly List<TagCollection> chapterPropertyTags =
|
||||
[
|
||||
new PropertyTagCollection<LibraryBookDto>(caseSensitive: true, CommonFormatters.StringFormatter)
|
||||
{
|
||||
{ TemplateTags.Title, lb => lb.TitleWithSubtitle },
|
||||
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
|
||||
{ TemplateTags.TitleShort, lb => GetTitleShort(lb.Title) },
|
||||
{ TemplateTags.AudibleTitle, lb => lb.Title },
|
||||
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
|
||||
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter },
|
||||
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, FormattableFormatter },
|
||||
{ TemplateTags.Series, lb => lb.Series, SeriesListFormat.Formatter, SeriesListFormat.Finalizer },
|
||||
{ TemplateTags.FirstSeries, lb => lb.FirstSeries, CommonFormatters.FormattableFormatter },
|
||||
},
|
||||
new PropertyTagCollection<MultiConvertFileProperties>(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter)
|
||||
new PropertyTagCollection<MultiConvertFileProperties>(caseSensitive: true, CommonFormatters.StringFormatter, CommonFormatters.IntegerFormatter, CommonFormatters.DateTimeFormatter)
|
||||
{
|
||||
{ TemplateTags.ChCount, m => m.PartsTotal },
|
||||
{ TemplateTags.ChNumber, m => m.PartsPosition },
|
||||
@@ -310,113 +323,81 @@ public abstract class Templates
|
||||
{ TemplateTags.ChTitle, m => m.Title },
|
||||
{ TemplateTags.FileDate, m => m.FileDate }
|
||||
}
|
||||
};
|
||||
];
|
||||
|
||||
private static readonly ConditionalTagCollection<LibraryBookDto> conditionalTags = new()
|
||||
{
|
||||
{ TemplateTags.IfAbridged, lb => lb.IsAbridged },
|
||||
{ TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent },
|
||||
{ TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent },
|
||||
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent },
|
||||
{ TemplateTags.IfBookseries, lb => lb is { IsSeries: true, IsPodcast: false, IsPodcastParent: false } },
|
||||
};
|
||||
|
||||
private static readonly ConditionalTagCollection<CombinedDto> combinedConditionalTags = new()
|
||||
{
|
||||
{ TemplateTags.Has, HasValue}
|
||||
{ TemplateTags.Is, TryGetValue },
|
||||
{ TemplateTags.Has, TryGetValue, HasValue }
|
||||
};
|
||||
|
||||
private static bool HasValue(ITemplateTag tag, CombinedDto dtos, string condition)
|
||||
{
|
||||
foreach (var c in chapterPropertyTags.OfType<PropertyTagCollection<LibraryBookDto>>().Append(filePropertyTags).Append(audioFilePropertyTags))
|
||||
{
|
||||
if (c.TryGetValue(condition, dtos.LibraryBook, out var value))
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
|
||||
if (dtos.MultiConvert is null)
|
||||
return false;
|
||||
|
||||
foreach (var c in chapterPropertyTags.OfType<PropertyTagCollection<MultiConvertFileProperties>>())
|
||||
{
|
||||
if (c.TryGetValue(condition, dtos.MultiConvert, out var value))
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(value);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static readonly ConditionalTagCollection<LibraryBookDto> folderConditionalTags = new()
|
||||
{
|
||||
{ TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent }
|
||||
};
|
||||
|
||||
private static readonly List<TagCollection> allPropertyTags =
|
||||
chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).ToList();
|
||||
|
||||
private static object? TryGetValue(ITemplateTag _, CombinedDto dtos, string property, CultureInfo? culture)
|
||||
{
|
||||
foreach (var c in allPropertyTags.OfType<PropertyTagCollection<LibraryBookDto>>())
|
||||
{
|
||||
if (c.TryGetObject(property, dtos.LibraryBook, culture, out var value))
|
||||
return value;
|
||||
}
|
||||
|
||||
if (dtos.MultiConvert is null)
|
||||
return null;
|
||||
|
||||
foreach (var c in allPropertyTags.OfType<PropertyTagCollection<MultiConvertFileProperties>>())
|
||||
{
|
||||
if (c.TryGetObject(property, dtos.MultiConvert, culture, out var value))
|
||||
return value;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static bool HasValue(object? value, CultureInfo? culture)
|
||||
{
|
||||
bool CheckItem(object o, CultureInfo? _) => !string.IsNullOrWhiteSpace(o.ToString());
|
||||
return value switch
|
||||
{
|
||||
null => false,
|
||||
IEnumerable<object> e => e.Any(o => CheckItem(o, culture)),
|
||||
_ => CheckItem(value, culture)
|
||||
};
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Tag Formatters
|
||||
|
||||
private static string? getTitleShort(string? title)
|
||||
=> title?.IndexOf(':') > 0 ? title.Substring(0, title.IndexOf(':')) : title;
|
||||
|
||||
private static string getLanguageShort(string? language)
|
||||
{
|
||||
if (language is null)
|
||||
return "";
|
||||
|
||||
language = language.Trim();
|
||||
if (language.Length <= 3)
|
||||
return language.ToUpper();
|
||||
return language[..3].ToUpper();
|
||||
}
|
||||
|
||||
private static string FormattableFormatter(ITemplateTag templateTag, IFormattable? value, string formatString)
|
||||
=> value?.ToString(formatString, null) ?? "";
|
||||
|
||||
private static string StringFormatter(ITemplateTag templateTag, string value, string formatString)
|
||||
{
|
||||
if (value is null) return "";
|
||||
else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper();
|
||||
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower();
|
||||
else return value;
|
||||
}
|
||||
|
||||
private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString)
|
||||
=> FloatFormatter(templateTag, value, formatString);
|
||||
|
||||
private static string FloatFormatter(ITemplateTag templateTag, float value, string formatString)
|
||||
{
|
||||
if (int.TryParse(formatString, out var numDigits) && numDigits > 0)
|
||||
{
|
||||
//Zero-pad the integer part
|
||||
var strValue = value.ToString();
|
||||
var decIndex = strValue.IndexOf(System.Globalization.NumberFormatInfo.CurrentInfo.NumberDecimalSeparator);
|
||||
var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex);
|
||||
|
||||
return new string('0', zeroPad) + strValue;
|
||||
}
|
||||
return value.ToString(formatString);
|
||||
}
|
||||
|
||||
private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString)
|
||||
{
|
||||
if (string.IsNullOrEmpty(formatString))
|
||||
return value.ToString(TemplateTags.DEFAULT_DATE_FORMAT);
|
||||
return value.ToString(formatString);
|
||||
}
|
||||
private static string? GetTitleShort(string? title)
|
||||
=> title != null && title.IndexOf(':') is var i && i >= 0
|
||||
? title[..i]
|
||||
: title;
|
||||
|
||||
#endregion
|
||||
|
||||
public class FolderTemplate : Templates, ITemplate
|
||||
{
|
||||
public static string Name { get; } = "Folder Template";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
|
||||
public static string DefaultTemplate { get; } = "<title short> [<id>]";
|
||||
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags, combinedConditionalTags];
|
||||
|
||||
public override IEnumerable<string> Errors
|
||||
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
|
||||
=> TemplateText.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ErrorFullPathIsInvalid) : base.Errors;
|
||||
|
||||
protected override List<string> GetTemplatePartsStrings(List<TemplatePart> parts, ReplacementCharacters replacements)
|
||||
=> parts
|
||||
@@ -430,7 +411,7 @@ public abstract class Templates
|
||||
public class FileTemplate : Templates, ITemplate
|
||||
{
|
||||
public static string Name { get; } = "File Template";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate)) ?? "";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate));
|
||||
public static string DefaultTemplate { get; } = "<title> [<id>]";
|
||||
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, combinedConditionalTags];
|
||||
}
|
||||
@@ -438,7 +419,7 @@ public abstract class Templates
|
||||
public class ChapterFileTemplate : Templates, ITemplate
|
||||
{
|
||||
public static string Name { get; } = "Chapter File Template";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate)) ?? "";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
|
||||
public static string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
|
||||
public static IEnumerable<TagCollection> TagCollections { get; }
|
||||
= chapterPropertyTags.Append(filePropertyTags).Append(audioFilePropertyTags).Append(conditionalTags).Append(combinedConditionalTags);
|
||||
@@ -446,13 +427,13 @@ public abstract class Templates
|
||||
public override IEnumerable<string> Warnings
|
||||
=> NamingTemplate.TagsInUse.Any(t => t.TagName.In(TemplateTags.ChNumber.TagName, TemplateTags.ChNumber0.TagName))
|
||||
? base.Warnings
|
||||
: base.Warnings.Append(WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
: base.Warnings.Append(WarningNoChapterNumberTag);
|
||||
}
|
||||
|
||||
public class ChapterTitleTemplate : Templates, ITemplate
|
||||
{
|
||||
public static string Name { get; } = "Chapter Title Template";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate)) ?? "";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
|
||||
public static string DefaultTemplate => "<ch#> - <title short>: <ch title>";
|
||||
public static IEnumerable<TagCollection> TagCollections { get; } = chapterPropertyTags.Append(conditionalTags).Append(combinedConditionalTags);
|
||||
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>
|
||||
/// User-facing copy when Audible denies a content license (download/decrypt). Covers temporary
|
||||
/// service issues and Audible Plus throttling — often mistaken for a Libation bug.
|
||||
/// Shared by WinForms and Avalonia via the process queue.
|
||||
/// </summary>
|
||||
public static class ContentLicenseDeniedUserMessage
|
||||
{
|
||||
public const string DialogCaption = "Content license denied";
|
||||
|
||||
/// <summary>Generic outage / GenericError-style denial: not specific to Plus titles.</summary>
|
||||
public static string BuildDialogBodyForPossibleOutage(string bookTitleWithSubtitle)
|
||||
=> $"""
|
||||
You were denied a content license for {bookTitleWithSubtitle}
|
||||
|
||||
This error often reflects a temporary interruption of service on Audible's side. It usually resolves within about 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app.
|
||||
|
||||
Heavy use of the Audible Plus catalog in a short time can also produce "license denied" responses; community reports often involve on the order of dozens of titles — Audible does not publish a fixed limit. Waiting 24 to 48 hours before trying again is usually enough.
|
||||
|
||||
If the problem continues after several days, open an issue on Libation's GitHub and include your logs.
|
||||
""";
|
||||
|
||||
/// <summary>License denied on an Audible Plus title — often rate limiting, not a Libation defect.</summary>
|
||||
public static string BuildDialogBodyForPlusCatalog(string bookTitleWithSubtitle)
|
||||
=> $"""
|
||||
You were denied a content license for {bookTitleWithSubtitle}
|
||||
|
||||
This title is from the Audible Plus catalog. Audible sometimes temporarily denies content licenses after heavy Plus use in a short period; community reports often mention on the order of dozens of downloads — Audible does not publish a fixed limit. This is usually not a Libation bug.
|
||||
|
||||
Try waiting 24 to 48 hours and liberate again. If it still fails after several days, open an issue on Libation's GitHub with logs.
|
||||
|
||||
If you should not have access to this title (for example it left Plus before you downloaded), confirm in the Audible app or website.
|
||||
""";
|
||||
}
|
||||
@@ -35,6 +35,7 @@ public class LibationContributor
|
||||
GitHubUser("CharlieRussel"),
|
||||
GitHubUser("cbordeman"),
|
||||
GitHubUser("jwillikers"),
|
||||
GitHubUser("Jo-Be-Co"),
|
||||
GitHubUser("Shuvashish76"),
|
||||
GitHubUser("RokeJulianLockhart"),
|
||||
GitHubUser("maaximal"),
|
||||
@@ -58,5 +59,5 @@ public class LibationContributor
|
||||
}
|
||||
|
||||
private static LibationContributor GitHubUser(string name, LibationContributorType type = LibationContributorType.Contributor)
|
||||
=> new LibationContributor(name, type, new Uri($"ht" + $"tps://github.com/{name.Replace('.', '-')}"));
|
||||
=> new(name, type, new Uri($"ht" + $"tps://github.com/{name.Replace('.', '-')}"));
|
||||
}
|
||||
@@ -237,5 +237,7 @@ public class LibationSetup
|
||||
};
|
||||
var contents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
|
||||
File.WriteAllText(settingsFilePath, contents);
|
||||
|
||||
EssentialFileValidator.ValidateCreatedAndReport(settingsFilePath);
|
||||
}
|
||||
}
|
||||
@@ -58,16 +58,17 @@ public class ProcessBookViewModel : ReactiveObject
|
||||
public bool IsDownloading => Status is ProcessBookStatus.Working;
|
||||
public bool Queued => Status is ProcessBookStatus.Queued;
|
||||
|
||||
public string StatusText => Result switch
|
||||
public string StatusText => (Result, LibraryBook.IsAudiblePlus) switch
|
||||
{
|
||||
ProcessBookResult.Success => "Finished",
|
||||
ProcessBookResult.Cancelled => "Cancelled",
|
||||
ProcessBookResult.ValidationFail => "Validation fail",
|
||||
ProcessBookResult.FailedRetry => "Error, will retry later",
|
||||
ProcessBookResult.FailedSkip => "Error, Skipping",
|
||||
ProcessBookResult.FailedAbort => "Error, Abort",
|
||||
ProcessBookResult.LicenseDenied => "License Denied",
|
||||
ProcessBookResult.LicenseDeniedPossibleOutage => "Possible Service Interruption",
|
||||
(ProcessBookResult.Success, _) => "Finished",
|
||||
(ProcessBookResult.Cancelled, _) => "Cancelled",
|
||||
(ProcessBookResult.ValidationFail, _) => "Validation fail",
|
||||
(ProcessBookResult.FailedRetry, _) => "Error, will retry later",
|
||||
(ProcessBookResult.FailedSkip, _) => "Error, Skipping",
|
||||
(ProcessBookResult.FailedAbort, _) => "Error, Abort",
|
||||
(ProcessBookResult.LicenseDenied, true) => "License denied (Plus; often temporary)",
|
||||
(ProcessBookResult.LicenseDenied, false) => "License Denied",
|
||||
(ProcessBookResult.LicenseDeniedPossibleOutage, _) => "Possible Service Interruption",
|
||||
_ => Status.ToString(),
|
||||
};
|
||||
|
||||
@@ -161,6 +162,11 @@ public class ProcessBookViewModel : ReactiveObject
|
||||
LogInfo($"{procName}: Content license was denied, but this error appears to be caused by a temporary interruption of service. - {LibraryBook.Book}");
|
||||
result = ProcessBookResult.LicenseDeniedPossibleOutage;
|
||||
}
|
||||
else if (LibraryBook.IsAudiblePlus)
|
||||
{
|
||||
LogInfo($"{procName}: Content license denied for this Audible Plus catalog title. Audible often throttles license requests after heavy Plus use; try again in 1 to 2 days. If you should not have access, check the Audible app. - {LibraryBook.Book}");
|
||||
result = ProcessBookResult.LicenseDenied;
|
||||
}
|
||||
else
|
||||
{
|
||||
LogInfo($"{procName}: Content license denied. Check your Audible account to see if you have access to this title. - {LibraryBook.Book}");
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
@@ -274,7 +275,7 @@ public class ProcessQueueViewModel : ReactiveObject
|
||||
RunningTime = string.Empty;
|
||||
ProgressBarVisible = true;
|
||||
var startingTime = DateTime.Now;
|
||||
bool shownServiceOutageMessage = false;
|
||||
bool shownLicenseGuidanceMessage = false;
|
||||
|
||||
using var counterTimer = new System.Threading.Timer(_ => RunningTime = timeToStr(DateTime.Now - startingTime), null, 0, 500);
|
||||
|
||||
@@ -299,17 +300,19 @@ public class ProcessQueueViewModel : ReactiveObject
|
||||
Queue.ClearQueue();
|
||||
else if (result == ProcessBookResult.FailedSkip)
|
||||
await nextBook.LibraryBook.UpdateBookStatusAsync(LiberatedStatus.Error);
|
||||
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage)
|
||||
else if (!shownLicenseGuidanceMessage
|
||||
&& (result == ProcessBookResult.LicenseDeniedPossibleOutage
|
||||
|| (result == ProcessBookResult.LicenseDenied && nextBook.LibraryBook.IsAudiblePlus)))
|
||||
{
|
||||
await MessageBoxBase.Show($"""
|
||||
You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle}
|
||||
|
||||
This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app.
|
||||
""",
|
||||
"Possible Interruption of Service",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Asterisk);
|
||||
shownServiceOutageMessage = true;
|
||||
var body = result == ProcessBookResult.LicenseDeniedPossibleOutage
|
||||
? ContentLicenseDeniedUserMessage.BuildDialogBodyForPossibleOutage(nextBook.LibraryBook.Book.TitleWithSubtitle)
|
||||
: ContentLicenseDeniedUserMessage.BuildDialogBodyForPlusCatalog(nextBook.LibraryBook.Book.TitleWithSubtitle);
|
||||
await MessageBoxBase.Show(
|
||||
body,
|
||||
ContentLicenseDeniedUserMessage.DialogCaption,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Asterisk);
|
||||
shownLicenseGuidanceMessage = true;
|
||||
}
|
||||
ProcessEnd?.Invoke(this, nextBook);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
using System;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace LibationUiBase;
|
||||
|
||||
/// <summary>
|
||||
/// User-facing copy and exception matching when embedded sign-in browser fails (often mistaken for a "library scan" bug).
|
||||
/// Shared by WinForms and Avalonia; stack markers include WebView2 and Avalonia's NativeWebDialog.
|
||||
/// </summary>
|
||||
public static class WebView2LoginErrorMessage
|
||||
{
|
||||
public const string Caption = "Sign-in browser could not start";
|
||||
|
||||
public static string ExplainerBody =>
|
||||
"Libation could not start the in-app sign-in browser. On Windows this uses Microsoft WebView2. "
|
||||
+ "This is a local sign-in or system setup issue — not a failure of the library scan itself.\r\n\r\n"
|
||||
+ "Things to try:\r\n"
|
||||
+ "• On Windows: install or repair the Microsoft Edge WebView2 Runtime from https://developer.microsoft.com/microsoft-edge/webview2/\r\n"
|
||||
+ "• In Libation Settings, try turning off the option to use the embedded browser and use external browser sign-in instead.\r\n"
|
||||
+ "• Check that security software is not blocking Libation or the embedded browser.\r\n"
|
||||
+ "• Ensure your account can write to local app data folders (permissions).\r\n"
|
||||
+ "• If you run as Administrator, try running Libation as a normal user (or the reverse).\r\n\r\n"
|
||||
+ "After sign-in works, use Import again to scan your library.";
|
||||
|
||||
public static bool IsWebView2SignInInfrastructureFailure(Exception ex)
|
||||
{
|
||||
for (var e = ex; e is not null; e = e.InnerException)
|
||||
{
|
||||
if (!StackMentionsEmbeddedSignInBrowser(e))
|
||||
continue;
|
||||
|
||||
if (e is UnauthorizedAccessException)
|
||||
return true;
|
||||
|
||||
if (e is COMException com
|
||||
&& (com.HResult == unchecked((int)0x8000FFFF) || com.HResult == unchecked((int)0x80070005)))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool TryFindInTree(Exception ex, out Exception? match)
|
||||
{
|
||||
Exception? found = null;
|
||||
walk(ex);
|
||||
match = found;
|
||||
return found is not null;
|
||||
|
||||
void walk(Exception? e)
|
||||
{
|
||||
if (e is null || found is not null)
|
||||
return;
|
||||
if (IsWebView2SignInInfrastructureFailure(e))
|
||||
found = e;
|
||||
if (found is not null)
|
||||
return;
|
||||
if (e is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
walk(inner);
|
||||
}
|
||||
walk(e.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool StackMentionsEmbeddedSignInBrowser(Exception e)
|
||||
{
|
||||
var stack = e.StackTrace;
|
||||
return stack is not null
|
||||
&& (stack.Contains("WebView2", StringComparison.Ordinal)
|
||||
|| stack.Contains("CoreWebView2", StringComparison.Ordinal)
|
||||
|| stack.Contains("NativeWebDialog", StringComparison.Ordinal));
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
using AudibleApi;
|
||||
using Dinah.Core;
|
||||
using LibationUiBase;
|
||||
using Microsoft.Web.WebView2.Core;
|
||||
using Microsoft.Web.WebView2.WinForms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace LibationWinForms.Login;
|
||||
@@ -30,32 +32,48 @@ public partial class WebLoginDialog : Form
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(choiceIn?.LoginUrl, nameof(choiceIn));
|
||||
this.Load += async (_, _) =>
|
||||
{
|
||||
//enable private browsing
|
||||
var env = await CoreWebView2Environment.CreateAsync();
|
||||
var options = env.CreateCoreWebView2ControllerOptions();
|
||||
options.IsInPrivateModeEnabled = true;
|
||||
await webView.EnsureCoreWebView2Async(env, options);
|
||||
|
||||
webView.CoreWebView2.Settings.UserAgent = Resources.User_Agent;
|
||||
|
||||
//Load init cookies
|
||||
foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? [])
|
||||
try
|
||||
{
|
||||
try
|
||||
{
|
||||
webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to set cookie {cookie.Name} for domain {cookie.Domain}");
|
||||
}
|
||||
await initWebViewAndNavigateAsync(choiceIn);
|
||||
}
|
||||
catch (Exception ex) when (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(this, WebView2LoginErrorMessage.ExplainerBody, WebView2LoginErrorMessage.Caption, ex);
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
}
|
||||
|
||||
webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded;
|
||||
Invoke(() => webView.Source = new Uri(choiceIn.LoginUrl));
|
||||
};
|
||||
}
|
||||
|
||||
private async Task initWebViewAndNavigateAsync(ChoiceIn choiceIn)
|
||||
{
|
||||
// enable private browsing
|
||||
var env = await CoreWebView2Environment.CreateAsync();
|
||||
var options = env.CreateCoreWebView2ControllerOptions();
|
||||
options.IsInPrivateModeEnabled = true;
|
||||
await webView.EnsureCoreWebView2Async(env, options);
|
||||
|
||||
webView.CoreWebView2.Settings.UserAgent = Resources.User_Agent;
|
||||
|
||||
// Load init cookies
|
||||
foreach (System.Net.Cookie cookie in choiceIn.SignInCookies ?? [])
|
||||
{
|
||||
if (string.IsNullOrEmpty(cookie.Value))
|
||||
continue;
|
||||
try
|
||||
{
|
||||
webView.CoreWebView2.CookieManager.AddOrUpdateCookie(webView.CoreWebView2.CookieManager.CreateCookieWithSystemNetCookie(cookie));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to set cookie {cookie.Name} for domain {cookie.Domain}");
|
||||
}
|
||||
}
|
||||
|
||||
webView.CoreWebView2.DOMContentLoaded += CoreWebView2_DOMContentLoaded;
|
||||
Invoke(() => webView.Source = new Uri(choiceIn.LoginUrl));
|
||||
}
|
||||
|
||||
private void WebView_NavigationStarting(object? sender, CoreWebView2NavigationStartingEventArgs e)
|
||||
{
|
||||
if (e.Uri.Contains("/ap/maplanding") is true)
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
using AudibleApi;
|
||||
using AudibleUtilities;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationWinForms.Dialogs.Login;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
@@ -24,7 +26,7 @@ public class WinformLoginChoiceEager : ILoginChoiceEager
|
||||
|
||||
private Task<ChoiceOut?> StartAsyncInternal(ChoiceIn choiceIn)
|
||||
{
|
||||
if (Environment.OSVersion.Version.Major >= 10)
|
||||
if (Configuration.Instance.UseWebView && Environment.OSVersion.Version.Major >= 10)
|
||||
{
|
||||
try
|
||||
{
|
||||
@@ -35,6 +37,8 @@ public class WinformLoginChoiceEager : ILoginChoiceEager
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"Failed to run {nameof(WebLoginDialog)}");
|
||||
if (WebView2LoginErrorMessage.IsWebView2SignInInfrastructureFailure(ex))
|
||||
MessageBoxLib.ShowAdminAlert(Owner, WebView2LoginErrorMessage.ExplainerBody, WebView2LoginErrorMessage.Caption, ex);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+21
-8
@@ -71,6 +71,7 @@
|
||||
autoDownloadEpisodesCb = new System.Windows.Forms.CheckBox();
|
||||
autoScanCb = new System.Windows.Forms.CheckBox();
|
||||
showImportedStatsCb = new System.Windows.Forms.CheckBox();
|
||||
useWebViewCb = new System.Windows.Forms.CheckBox();
|
||||
tab3DownloadDecrypt = new System.Windows.Forms.TabPage();
|
||||
saveMetadataToFileCbox = new System.Windows.Forms.CheckBox();
|
||||
useCoverAsFolderIconCb = new System.Windows.Forms.CheckBox();
|
||||
@@ -212,20 +213,20 @@
|
||||
// importEpisodesCb
|
||||
//
|
||||
importEpisodesCb.AutoSize = true;
|
||||
importEpisodesCb.Location = new System.Drawing.Point(6, 56);
|
||||
importEpisodesCb.Location = new System.Drawing.Point(6, 81);
|
||||
importEpisodesCb.Name = "importEpisodesCb";
|
||||
importEpisodesCb.Size = new System.Drawing.Size(146, 19);
|
||||
importEpisodesCb.TabIndex = 3;
|
||||
importEpisodesCb.TabIndex = 4;
|
||||
importEpisodesCb.Text = "[import episodes desc]";
|
||||
importEpisodesCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// downloadEpisodesCb
|
||||
//
|
||||
downloadEpisodesCb.AutoSize = true;
|
||||
downloadEpisodesCb.Location = new System.Drawing.Point(6, 106);
|
||||
downloadEpisodesCb.Location = new System.Drawing.Point(6, 131);
|
||||
downloadEpisodesCb.Name = "downloadEpisodesCb";
|
||||
downloadEpisodesCb.Size = new System.Drawing.Size(163, 19);
|
||||
downloadEpisodesCb.TabIndex = 5;
|
||||
downloadEpisodesCb.TabIndex = 6;
|
||||
downloadEpisodesCb.Text = "[download episodes desc]";
|
||||
downloadEpisodesCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
@@ -594,6 +595,7 @@
|
||||
tab2ImportLibrary.Controls.Add(autoDownloadEpisodesCb);
|
||||
tab2ImportLibrary.Controls.Add(autoScanCb);
|
||||
tab2ImportLibrary.Controls.Add(showImportedStatsCb);
|
||||
tab2ImportLibrary.Controls.Add(useWebViewCb);
|
||||
tab2ImportLibrary.Controls.Add(importEpisodesCb);
|
||||
tab2ImportLibrary.Controls.Add(downloadEpisodesCb);
|
||||
tab2ImportLibrary.Location = new System.Drawing.Point(4, 24);
|
||||
@@ -606,20 +608,20 @@
|
||||
// importPlusTitlesCb
|
||||
//
|
||||
importPlusTitlesCb.AutoSize = true;
|
||||
importPlusTitlesCb.Location = new System.Drawing.Point(6, 81);
|
||||
importPlusTitlesCb.Location = new System.Drawing.Point(6, 106);
|
||||
importPlusTitlesCb.Name = "importPlusTitlesCb";
|
||||
importPlusTitlesCb.Size = new System.Drawing.Size(199, 19);
|
||||
importPlusTitlesCb.TabIndex = 4;
|
||||
importPlusTitlesCb.TabIndex = 5;
|
||||
importPlusTitlesCb.Text = "[import audible plus books desc]";
|
||||
importPlusTitlesCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// autoDownloadEpisodesCb
|
||||
//
|
||||
autoDownloadEpisodesCb.AutoSize = true;
|
||||
autoDownloadEpisodesCb.Location = new System.Drawing.Point(6, 131);
|
||||
autoDownloadEpisodesCb.Location = new System.Drawing.Point(6, 156);
|
||||
autoDownloadEpisodesCb.Name = "autoDownloadEpisodesCb";
|
||||
autoDownloadEpisodesCb.Size = new System.Drawing.Size(190, 19);
|
||||
autoDownloadEpisodesCb.TabIndex = 6;
|
||||
autoDownloadEpisodesCb.TabIndex = 7;
|
||||
autoDownloadEpisodesCb.Text = "[auto download episodes desc]";
|
||||
autoDownloadEpisodesCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
@@ -643,6 +645,16 @@
|
||||
showImportedStatsCb.Text = "[show imported stats desc]";
|
||||
showImportedStatsCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// useWebViewCb
|
||||
//
|
||||
useWebViewCb.AutoSize = true;
|
||||
useWebViewCb.Location = new System.Drawing.Point(6, 56);
|
||||
useWebViewCb.Name = "useWebViewCb";
|
||||
useWebViewCb.Size = new System.Drawing.Size(112, 19);
|
||||
useWebViewCb.TabIndex = 3;
|
||||
useWebViewCb.Text = "[use webview desc]";
|
||||
useWebViewCb.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// tab3DownloadDecrypt
|
||||
//
|
||||
tab3DownloadDecrypt.AutoScroll = true;
|
||||
@@ -1583,6 +1595,7 @@
|
||||
private System.Windows.Forms.Label label16;
|
||||
private System.Windows.Forms.CheckBox createCueSheetCbox;
|
||||
private System.Windows.Forms.CheckBox autoScanCb;
|
||||
private System.Windows.Forms.CheckBox useWebViewCb;
|
||||
private System.Windows.Forms.CheckBox downloadCoverArtCbox;
|
||||
private System.Windows.Forms.CheckBox autoDownloadEpisodesCb;
|
||||
private System.Windows.Forms.CheckBox saveEpisodesToSeriesFolderCbox;
|
||||
|
||||
@@ -8,13 +8,16 @@ public partial class SettingsDialog
|
||||
{
|
||||
this.autoScanCb.Text = desc(nameof(config.AutoScan));
|
||||
this.showImportedStatsCb.Text = desc(nameof(config.ShowImportedStats));
|
||||
this.useWebViewCb.Text = desc(nameof(config.UseWebView));
|
||||
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
|
||||
this.importPlusTitlesCb.Text = desc(nameof(config.ImportPlusTitles));
|
||||
toolTip.SetToolTip(importPlusTitlesCb, Configuration.ImportPlusTitlesToolTip);
|
||||
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
|
||||
this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes));
|
||||
|
||||
autoScanCb.Checked = config.AutoScan;
|
||||
showImportedStatsCb.Checked = config.ShowImportedStats;
|
||||
useWebViewCb.Checked = config.UseWebView;
|
||||
importEpisodesCb.Checked = config.ImportEpisodes;
|
||||
importPlusTitlesCb.Checked = config.ImportPlusTitles;
|
||||
downloadEpisodesCb.Checked = config.DownloadEpisodes;
|
||||
@@ -28,5 +31,6 @@ public partial class SettingsDialog
|
||||
config.ImportPlusTitles = importPlusTitlesCb.Checked;
|
||||
config.DownloadEpisodes = downloadEpisodesCb.Checked;
|
||||
config.AutoDownloadEpisodes = autoDownloadEpisodesCb.Checked;
|
||||
config.UseWebView = useWebViewCb.Checked;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using ApplicationServices;
|
||||
using AudibleUtilities;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationWinForms.Dialogs;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
@@ -87,11 +88,22 @@ public partial class Form1
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
this,
|
||||
"Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator",
|
||||
"Error importing library",
|
||||
ex);
|
||||
if (WebView2LoginErrorMessage.TryFindInTree(ex, out var webViewEx) && webViewEx is not null)
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
this,
|
||||
WebView2LoginErrorMessage.ExplainerBody,
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
webViewEx);
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBoxLib.ShowAdminAlert(
|
||||
this,
|
||||
"Error importing library. Please try again. If this still happens after 2 or 3 tries, stop and contact administrator",
|
||||
"Error importing library",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -36,6 +36,9 @@ static class Program
|
||||
|
||||
ApplicationConfiguration.Initialize();
|
||||
|
||||
// When essential file validation fails and the error cannot be written to the log, show the user
|
||||
EssentialFileValidator.ShowUserWhenLogUnavailable = msg => MessageBox.Show(msg, "Libation - Essential File Error", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
// do not use Configuration before this line //
|
||||
|
||||
@@ -19,7 +19,7 @@ internal class Walkthrough
|
||||
private readonly Dictionary<string, string> settingTabMessages = new()
|
||||
{
|
||||
{ "Important settings", "From here you can change where liberated books are stored and how detailed Libation's logs are.\r\n\r\nIf you experience a problem and need help, you'll be asked to provide your log file. In certain circumstances we may need you to reproduce the error with a higher level of logging detail."},
|
||||
{ "Import library", "In this tab you can change how your library is scanned and imported into Libation, as well as automatic liberation."},
|
||||
{ "Import library", "In this tab you can change how your library is scanned and imported into Libation, as well as automatic liberation.\r\n\r\nFor best use with screen readers, uncheck \"Use Libation's built-in web browser to log into Audible?\"."},
|
||||
{ "Download/Decrypt", "These settings allow you to control how liberated files and folders are named and stored.\r\nYou can customize the 'Naming Templates' to use any number of the audiobook's properties to build a customized file and folder naming format. Learn more about the syntax from the wiki at\r\n\r\n" + LibationScaffolding.NamingTemplatesDocUrl},
|
||||
{ "Audio File Options", "Control how audio files are decrypted, including audio format and metadata handling.\r\n\r\nIf you choose to split your audiobook into multiple files by chapter marker, you may edit the chapter file 'Naming Template' to control how each chapter file is named."},
|
||||
};
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
using AssertionHelper;
|
||||
using AudibleApi.Cryptography;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace AudibleUtilities.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class Mkb79AuthExportTests
|
||||
{
|
||||
static string MinimalMkb79Json(Action<JObject>? tweak = null)
|
||||
{
|
||||
var jo = new JObject
|
||||
{
|
||||
["website_cookies"] = new JObject(),
|
||||
["adp_token"] = "a",
|
||||
["access_token"] = "b",
|
||||
["refresh_token"] = "c",
|
||||
["device_private_key"] = "d",
|
||||
["store_authentication_cookie"] = new JObject { ["cookie"] = "" },
|
||||
["device_info"] = new JObject(),
|
||||
["customer_info"] = new JObject(),
|
||||
["expires"] = 0,
|
||||
["locale_code"] = "us",
|
||||
["with_username"] = false,
|
||||
};
|
||||
tweak?.Invoke(jo);
|
||||
return jo.ToString(Newtonsoft.Json.Formatting.None);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToJson_empty_website_cookies_is_null_not_object()
|
||||
{
|
||||
var auth = Mkb79Auth.FromJson(MinimalMkb79Json());
|
||||
auth.BeNotNull();
|
||||
var jo = JObject.Parse(auth.ToJson());
|
||||
Assert.AreEqual(JTokenType.Null, jo["website_cookies"]!.Type);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void ToJson_device_private_key_is_pem_with_64_char_lines()
|
||||
{
|
||||
var keyMaterial = Convert.ToBase64String(new byte[100]);
|
||||
var singleLine = PrivateKey.REQUIRED_BEGINNING + keyMaterial + PrivateKey.REQUIRED_ENDING;
|
||||
var auth = Mkb79Auth.FromJson(MinimalMkb79Json(j =>
|
||||
{
|
||||
j["website_cookies"] = JValue.CreateNull();
|
||||
j["device_private_key"] = singleLine;
|
||||
}));
|
||||
auth.BeNotNull();
|
||||
var pem = JObject.Parse(auth.ToJson())["device_private_key"]!.Value<string>()!;
|
||||
var lines = pem.Replace("\r\n", "\n").Split('\n', StringSplitOptions.RemoveEmptyEntries);
|
||||
lines[0].Should().Be(PrivateKey.REQUIRED_BEGINNING);
|
||||
lines[^1].Should().Be(PrivateKey.REQUIRED_ENDING);
|
||||
foreach (var body in lines.Skip(1).Take(lines.Length - 2))
|
||||
Assert.IsTrue(body.Length <= 64);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void Serialize_ToJson_matches_instance_ToJson()
|
||||
{
|
||||
var auth = Mkb79Auth.FromJson(MinimalMkb79Json(j => j["device_private_key"] = "AAAA"));
|
||||
auth.BeNotNull();
|
||||
auth.ToJson().Should().Be(Serialize.ToJson(auth));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using AssertionHelper;
|
||||
using FileManager.NamingTemplate;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace FileManager.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class CommonFormattersTests
|
||||
{
|
||||
[TestMethod]
|
||||
public void TemplateStringFormatter_UnknownTag_RemainsUnchanged()
|
||||
{
|
||||
// GIVEN
|
||||
var template = "Author: {AUTHOR}, Unknown: {UNKNOWN}, Title: {TITLE}";
|
||||
var replacements = new Dictionary<string, Func<TestClass, object?>>
|
||||
{
|
||||
["AUTHOR"] = obj => obj.Author,
|
||||
["TITLE"] = obj => obj.Title
|
||||
};
|
||||
var testObj = new TestClass { Author = "John Doe", Title = "Test Book" };
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.TemplateStringFormatter(testObj, template, CultureInfo.InvariantCulture, replacements);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("Author: John Doe, Unknown: {UNKNOWN}, Title: Test Book", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MinutesFormatter_Boundaries_ZeroMinutes()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "MINUTES" };
|
||||
var value = TimeSpan.FromMinutes(0);
|
||||
var format = @"h\:m";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("0:0", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MinutesFormatter_Boundaries_OneDay()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "MINUTES" };
|
||||
var value = TimeSpan.FromHours(24);
|
||||
var format = @"d'd 'h'h 'm\m";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("1d 0h 0m", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void MinutesFormatter_Boundaries_LargeValue()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "MINUTES" };
|
||||
var value = TimeSpan.FromHours(50); // 50 hours
|
||||
var format = @"d'd 'h'h 'm\m";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.MinutesFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("2d 2h 0m", result); // 2 days, 2 hours
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_InvalidCombinedFormat_ReturnsOriginal()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "TestString";
|
||||
var invalidFormat = "invalid format with spaces and numbers 123";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, invalidFormat, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("TestString", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void TemplateStringFormatter_InvalidCombinedFormat_HandlesGracefully()
|
||||
{
|
||||
// GIVEN
|
||||
var template = "{AUTHOR:invalid:format}, {TITLE}";
|
||||
var replacements = new Dictionary<string, Func<TestClass, object?>>
|
||||
{
|
||||
["AUTHOR"] = obj => obj.Author,
|
||||
["TITLE"] = obj => obj.Title
|
||||
};
|
||||
var testObj = new TestClass { Author = "John Doe", Title = "Test Book" };
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.TemplateStringFormatter(testObj, template, CultureInfo.InvariantCulture, replacements);
|
||||
|
||||
// THEN
|
||||
// Since AUTHOR is IFormattable? No, it's string, so uses _StringFormatter with invalid format
|
||||
Assert.AreEqual("John Doe, Test Book", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_Uppercase()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "test string";
|
||||
var format = "U";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("TEST STRING", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_Lowercase()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "TEST STRING";
|
||||
var format = "L";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("test string", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_TitleCase()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "test string";
|
||||
var format = "T";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("Test String", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_TitleCaseWithLength()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "test string longer";
|
||||
var format = "10T";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("Test Strin", result); // Title case first 10 chars
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void StringFormatter_MaxLength()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "STRING" };
|
||||
var value = "this is a very long string";
|
||||
var format = "20";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.StringFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("this is a very long ", result); // Truncated to 20 chars
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FormattableFormatter_Standard()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "FORMATTABLE" };
|
||||
var value = 123.45;
|
||||
var format = "F2";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.FormattableFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("123.45", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IntegerFormatter_WithLengthAndPadding()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "INTEGER" };
|
||||
var value = 42;
|
||||
var format = "5"; // Zero-padded to 5 digits
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.IntegerFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("00042", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void IntegerFormatter_StandardFormat()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "INTEGER" };
|
||||
var value = 1234;
|
||||
var format = "N0"; // Number format
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.IntegerFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("1,234", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FloatFormatter_WithLengthAndPadding()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "FLOAT" };
|
||||
var value = 12.34f;
|
||||
var format = "F3"; // Fixed-point with 3 decimals
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.FloatFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("12.340", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void FloatFormatter_StandardFormat()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "FLOAT" };
|
||||
var value = 1234.567f;
|
||||
var format = "N2"; // Number format with 2 decimals
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.FloatFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("1,234.57", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void DateTimeFormatter_Standard()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "DATETIME" };
|
||||
var value = new DateTime(2023, 10, 15, 14, 30, 0);
|
||||
var format = "yyyy-MM-dd";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.DateTimeFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("2023-10-15", result);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LanguageShortFormatter_TrimToThreeAndUppercase()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "LANGUAGE" };
|
||||
var value = "english";
|
||||
var format = ""; // Assuming default or empty
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.LanguageShortFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("ENG", result); // First 3 chars uppercase
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void LanguageShortFormatter_ShortLanguage()
|
||||
{
|
||||
// GIVEN
|
||||
var templateTag = new TemplateTag { TagName = "LANGUAGE" };
|
||||
var value = "de";
|
||||
var format = "";
|
||||
|
||||
// WHEN
|
||||
var result = CommonFormatters.LanguageShortFormatter(templateTag, value, format, CultureInfo.InvariantCulture);
|
||||
|
||||
// THEN
|
||||
Assert.AreEqual("DE", result); // Uppercase, no trim needed
|
||||
}
|
||||
|
||||
private class TestClass
|
||||
{
|
||||
public string? Author { get; set; }
|
||||
public string? Title { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using System.Reflection;
|
||||
using FileManager.NamingTemplate;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
|
||||
namespace FileManager.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class ConditionalTagCollectionTests
|
||||
{
|
||||
private class TestObject
|
||||
{
|
||||
public string? Value { get; init; }
|
||||
}
|
||||
|
||||
private class TestTag : ITemplateTag
|
||||
{
|
||||
public string TagName => "testcond";
|
||||
}
|
||||
|
||||
private readonly ConditionalTagCollection<TestObject> _conditionalTags = new()
|
||||
{
|
||||
{ new TestTag(), TryGetValue }
|
||||
};
|
||||
|
||||
private static object? TryGetValue(ITemplateTag _, TestObject obj, string condition, CultureInfo? culture)
|
||||
=> obj.Value;
|
||||
|
||||
/// <summary>
|
||||
/// Test that invalid regex patterns throw InvalidOperationException during evaluation.
|
||||
/// Tests include malformed patterns and catastrophic backtracking scenarios.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow("[abc", "test_value", DisplayName = "InvalidRegexPattern_UnmatchedBracket")]
|
||||
[DataRow("(?'name)abc", "test_value", DisplayName = "InvalidRegexPattern_InvalidGroup")]
|
||||
[DataRow("(a+)+b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", DisplayName = "CatastrophicBacktracking_NestedQuantifiers")]
|
||||
[DataRow("(a|aa|aaa|aaaa)*?b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", DisplayName = "CatastrophicBacktracking_AlternationOverlap")]
|
||||
[DataRow("(a+a+)+b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", DisplayName = "CatastrophicBacktracking_RepeatedConcatenation")]
|
||||
[DataRow("^(a+)+$", "aaaaaaaaaaaaaaaaaaaaaab", DisplayName = "CatastrophicBacktracking_AnchoredRepeated")]
|
||||
[DataRow("(a*)*b", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", DisplayName = "CatastrophicBacktracking_StarStar")]
|
||||
public void ConditionalTag_InvalidRegexPattern_ThrowsInvalidOperationException(string pattern, string testValue)
|
||||
{
|
||||
// Arrange: Invalid regex patterns that should throw InvalidOperationException during evaluation
|
||||
var template = $"<testcond foobar[~{pattern}]->content<-testcond>";
|
||||
var namingTemplate = NamingTemplate.NamingTemplate.Parse(template, [_conditionalTags]);
|
||||
|
||||
var testObj = new TestObject { Value = testValue };
|
||||
|
||||
// Act & Assert: Evaluate template with invalid regex, should throw InvalidOperationException
|
||||
try
|
||||
{
|
||||
namingTemplate.Evaluate(testObj);
|
||||
Assert.Fail($"Expected InvalidOperationException for pattern: {pattern}");
|
||||
}
|
||||
catch (TargetInvocationException ex)
|
||||
{
|
||||
// if evaluation of the template started but the regex is running into a timeout an InvalidOperationException is thrown
|
||||
Assert.IsInstanceOfType<InvalidOperationException>(ex.InnerException);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Expected behavior - regex is invalid and parsing should fail
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that valid simple regex patterns parse successfully and don't throw during evaluation.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
public void ConditionalTag_ValidRegexPattern_ParsesSuccessfully()
|
||||
{
|
||||
// Arrange: Valid simple regex pattern with proper closing tag
|
||||
var template = "<testcond foobar[~test.*]->content<-testcond>";
|
||||
|
||||
// Act: Parse should succeed without throwing exceptions
|
||||
var namingTemplate = NamingTemplate.NamingTemplate.Parse(template, [_conditionalTags]);
|
||||
|
||||
// Assert: Should parse successfully (may have warnings but no exceptions)
|
||||
Assert.IsNotNull(namingTemplate);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Test that regex patterns with special characters don't cause issues.
|
||||
/// </summary>
|
||||
[TestMethod]
|
||||
[DataRow("^test$", DisplayName = "RegexAnchors")]
|
||||
[DataRow("test.*value", DisplayName = "RegexWildcard")]
|
||||
[DataRow(@"[a-z\]+", DisplayName = "RegexCharacterClass")]
|
||||
[DataRow("test|value", DisplayName = "RegexAlternation")]
|
||||
public void ConditionalTag_ValidComplexRegexPatterns_ParseSuccessfully(string pattern)
|
||||
{
|
||||
// Arrange: Valid complex regex patterns with proper closing tags
|
||||
var template = $"<testcond foobar[~{pattern}]->c<-testcond>";
|
||||
|
||||
// Act: Parse should succeed without throwing
|
||||
var namingTemplate = NamingTemplate.NamingTemplate.Parse(template, [_conditionalTags]);
|
||||
|
||||
// Assert: Should parse successfully without exceptions
|
||||
Assert.IsNotNull(namingTemplate);
|
||||
}
|
||||
}
|
||||
@@ -1,9 +1,10 @@
|
||||
using AssertionHelper;
|
||||
using System.Globalization;
|
||||
using System.Linq;
|
||||
using AssertionHelper;
|
||||
using FileManager.NamingTemplate;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System.Linq;
|
||||
|
||||
namespace NamingTemplateTests;
|
||||
namespace FileManager.Tests;
|
||||
|
||||
class TemplateTag : ITemplateTag
|
||||
{
|
||||
@@ -40,7 +41,8 @@ class PropertyClass3
|
||||
public int? Int2 { get; set; }
|
||||
public bool Condition { get; set; }
|
||||
}
|
||||
class ReferenceType
|
||||
|
||||
internal abstract class ReferenceType
|
||||
{
|
||||
public override string ToString()
|
||||
{
|
||||
@@ -77,30 +79,37 @@ public class GetPortionFilename
|
||||
{ new TemplateTag { TagName = "null_3" }, i => i.NullItem },
|
||||
{ new TemplateTag { TagName = "reftype" }, i => i.RefType },
|
||||
};
|
||||
readonly ConditionalTagCollection<PropertyClass1> conditional1 = new()
|
||||
|
||||
private readonly ConditionalTagCollection<PropertyClass1> _conditional1 = new()
|
||||
{
|
||||
{ new TemplateTag { TagName = "ifc1" }, i => i.Condition },
|
||||
{ new TemplateTag { TagName = "has1" }, HasValue }
|
||||
{ new TemplateTag { TagName = "has1" }, TryGetValue, HasValue }
|
||||
};
|
||||
readonly ConditionalTagCollection<PropertyClass2> conditional2 = new()
|
||||
|
||||
private readonly ConditionalTagCollection<PropertyClass2> _conditional2 = new()
|
||||
{
|
||||
{ new TemplateTag { TagName = "ifc2" }, i => i.Condition },
|
||||
{ new TemplateTag { TagName = "has2" }, HasValue }
|
||||
{ new TemplateTag { TagName = "has2" }, TryGetValue, HasValue }
|
||||
};
|
||||
readonly ConditionalTagCollection<PropertyClass3> conditional3 = new()
|
||||
|
||||
private readonly ConditionalTagCollection<PropertyClass3> _conditional3 = new()
|
||||
{
|
||||
{ new TemplateTag { TagName = "ifc3" }, i => i.Condition },
|
||||
{ new TemplateTag { TagName = "has3" }, HasValue }
|
||||
{ new TemplateTag { TagName = "has3" }, TryGetValue, HasValue }
|
||||
};
|
||||
|
||||
private static bool HasValue(ITemplateTag templateTag, PropertyClass1 referenceType, string condition)
|
||||
=> props1.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
|
||||
private static bool HasValue(ITemplateTag templateTag, PropertyClass2 referenceType, string condition)
|
||||
=> props2.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
|
||||
private static bool HasValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition)
|
||||
=> props3.TryGetValue(condition, referenceType, out var value) && !string.IsNullOrEmpty(value);
|
||||
private static object? TryGetValue(ITemplateTag templateTag, PropertyClass1 referenceType, string condition, CultureInfo? culture)
|
||||
=> props1.TryGetObject(condition, referenceType, culture, out var value) ? value : null;
|
||||
|
||||
readonly PropertyClass1 propertyClass1 = new()
|
||||
private static object? TryGetValue(ITemplateTag templateTag, PropertyClass2 referenceType, string condition, CultureInfo? culture)
|
||||
=> props2.TryGetObject(condition, referenceType, culture, out var value) ? value : null;
|
||||
|
||||
private static object? TryGetValue(ITemplateTag templateTag, PropertyClass3 referenceType, string condition, CultureInfo? culture)
|
||||
=> props3.TryGetObject(condition, referenceType, culture, out var value) ? value : null;
|
||||
|
||||
private static bool HasValue(object? value, CultureInfo? culture) => value is not null && !string.IsNullOrWhiteSpace(value.ToString());
|
||||
|
||||
private readonly PropertyClass1 _propertyClass1 = new()
|
||||
{
|
||||
Item1 = "prop1_item1",
|
||||
Item2 = "prop1_item2",
|
||||
@@ -109,7 +118,7 @@ public class GetPortionFilename
|
||||
Condition = true,
|
||||
};
|
||||
|
||||
readonly PropertyClass2 propertyClass2 = new()
|
||||
private readonly PropertyClass2 _propertyClass2 = new()
|
||||
{
|
||||
Item1 = "prop2_item1",
|
||||
Item3 = "prop2_item3",
|
||||
@@ -117,7 +126,7 @@ public class GetPortionFilename
|
||||
Condition = false
|
||||
};
|
||||
|
||||
readonly PropertyClass3 propertyClass3 = new()
|
||||
private readonly PropertyClass3 _propertyClass3 = new()
|
||||
{
|
||||
Item1 = "prop3_item1",
|
||||
Item2 = "prop3_item2",
|
||||
@@ -141,15 +150,15 @@ public class GetPortionFilename
|
||||
[DataRow("<!ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><-ifc2>", "prop1_item1prop2_item4prop3_item2", 3)]
|
||||
[DataRow("<!has1 null_1-><has2 item1-><has3 item3_2-><item1><item4><item3_2><-has3><-has2><-has1>", "prop1_item1prop2_item4prop3_item2", 3)]
|
||||
[DataRow("<!has1 null_1->null_1 is null, <-has1><has2 item1-><item1><-has2><has3 item3_2-><item3_2><-has3>", "null_1 is null, prop1_item1prop3_item2", 2)]
|
||||
public void test(string inStr, string outStr, int numTags)
|
||||
public void Test(string inStr, string outStr, int numTags)
|
||||
{
|
||||
var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 });
|
||||
var template = NamingTemplate.NamingTemplate.Parse(inStr, [props1, props2, props3, _conditional1, _conditional2, _conditional3]);
|
||||
|
||||
template.TagsInUse.Should().HaveCount(numTags);
|
||||
template.Warnings.Should().HaveCount(numTags > 0 ? 0 : 1);
|
||||
template.Errors.Should().HaveCount(0);
|
||||
|
||||
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
|
||||
var templateText = string.Concat(template.Evaluate(null, _propertyClass3, _propertyClass2, _propertyClass1).Select(v => v.Value));
|
||||
|
||||
templateText.Should().Be(outStr);
|
||||
}
|
||||
@@ -174,12 +183,12 @@ public class GetPortionFilename
|
||||
[DataRow("<has3 item3_1 ->true<-has3>", "true")]
|
||||
public void Has_test(string inStr, string outStr)
|
||||
{
|
||||
var template = NamingTemplate.Parse(inStr, [props1, props2, props3, conditional1, conditional2, conditional3]);
|
||||
var template = NamingTemplate.NamingTemplate.Parse(inStr, [props1, props2, props3, _conditional1, _conditional2, _conditional3]);
|
||||
|
||||
template.Warnings.Should().HaveCount(1);
|
||||
template.Errors.Should().HaveCount(0);
|
||||
|
||||
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
|
||||
var templateText = string.Concat(template.Evaluate(null, _propertyClass3, _propertyClass2, _propertyClass1).Select(v => v.Value));
|
||||
|
||||
templateText.Should().Be(outStr);
|
||||
}
|
||||
@@ -198,35 +207,36 @@ public class GetPortionFilename
|
||||
[DataRow("<has3 item3_1->true< -has3>", "true< -has3>")]
|
||||
public void Has_invalid(string inStr, string outStr)
|
||||
{
|
||||
var template = NamingTemplate.Parse(inStr, [props1, props2, props3, conditional1, conditional2, conditional3]);
|
||||
var template = NamingTemplate.NamingTemplate.Parse(inStr, [props1, props2, props3, _conditional1, _conditional2, _conditional3]);
|
||||
|
||||
template.Warnings.Should().HaveCount(2);
|
||||
template.Errors.Should().HaveCount(0);
|
||||
|
||||
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
|
||||
var templateText = string.Concat(template.Evaluate(null, _propertyClass3, _propertyClass2, _propertyClass1).Select(v => v.Value));
|
||||
|
||||
templateText.Should().Be(outStr);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new string[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<has2-><has1-><has3-><item1><item4><item3_2><-has3><-has1><has2->", new string[] { "Missing <-has2> closing conditional.", "Missing <-has2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Should use tags. Eg: <title>" })]
|
||||
[DataRow("<ifc1-><ifc3-><item1><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional." })]
|
||||
[DataRow("<ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new string[] { "Missing <ifc2-> open conditional.", "Should use tags. Eg: <title>" })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1>", new string[] { "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3>", new string[] { "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc1><-ifc2>", new string[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1><ifc2->", new[] { "Missing <-ifc2> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<has2-><has1-><has3-><item1><item4><item3_2><-has3><-has1><has2->", new[] { "Missing <-has2> closing conditional.", "Missing <-has2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new[] { "Should use tags. Eg: <title>" })]
|
||||
[DataRow("<ifc1-><ifc3-><item1><-ifc3><-ifc1><-ifc2>", new[] { "Missing <ifc2-> open conditional." })]
|
||||
[DataRow("<ifc1-><ifc3-><-ifc3><-ifc1><-ifc2>", new[] { "Missing <ifc2-> open conditional.", "Should use tags. Eg: <title>" })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3><-ifc1>", new[] { "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc3>", new[] { "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4>", new[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
[DataRow("<ifc2-><ifc1-><ifc3-><item1><item4><item3_2><-ifc1><-ifc2>",
|
||||
new[] { "Missing <-ifc3> closing conditional.", "Missing <-ifc3> closing conditional.", "Missing <-ifc1> closing conditional.", "Missing <-ifc2> closing conditional." })]
|
||||
public void condition_error(string inStr, string[] warnings)
|
||||
{
|
||||
var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 });
|
||||
var template = NamingTemplate.NamingTemplate.Parse(inStr, [props1, props2, props3, _conditional1, _conditional2, _conditional3]);
|
||||
|
||||
template.Errors.Should().HaveCount(0);
|
||||
template.Warnings.Should().BeEquivalentTo(warnings);
|
||||
}
|
||||
|
||||
static string GetVal(ITemplateTag templateTag, ReferenceType referenceType, string format)
|
||||
static string GetVal(ITemplateTag templateTag, ReferenceType referenceType, string format, CultureInfo? culture)
|
||||
{
|
||||
return "";
|
||||
}
|
||||
@@ -247,35 +257,32 @@ public class GetPortionFilename
|
||||
[DataRow("<item2_2_null[]>", "")]
|
||||
[DataRow("<item2_2_null[l]>", "")]
|
||||
[DataRow("<reftype[l]>", "")]
|
||||
public void formatting(string inStr, string outStr)
|
||||
public void Formatting(string inStr, string outStr)
|
||||
{
|
||||
props1.Add(new TemplateTag { TagName = "int1" }, i => i.Int1, formatInt);
|
||||
props3.Add(new TemplateTag { TagName = "int2" }, i => i.Int2, formatInt);
|
||||
props3.Add(new TemplateTag { TagName = "item3_format" }, i => i.Item3, formatString);
|
||||
props2.Add(new TemplateTag { TagName = "item2_2_null" }, i => i.Item2, formatString);
|
||||
props1.Add(new TemplateTag { TagName = "int1" }, i => i.Int1, FormatInt);
|
||||
props3.Add(new TemplateTag { TagName = "int2" }, i => i.Int2, FormatInt);
|
||||
props3.Add(new TemplateTag { TagName = "item3_format" }, i => i.Item3, FormatString);
|
||||
props2.Add(new TemplateTag { TagName = "item2_2_null" }, i => i.Item2, FormatString);
|
||||
|
||||
var template = NamingTemplate.Parse(inStr, new TagCollection[] { props1, props2, props3, conditional1, conditional2, conditional3 });
|
||||
var template = NamingTemplate.NamingTemplate.Parse(inStr, [props1, props2, props3, _conditional1, _conditional2, _conditional3]);
|
||||
|
||||
template.Warnings.Should().HaveCount(0);
|
||||
template.Errors.Should().HaveCount(0);
|
||||
|
||||
var templateText = string.Concat(template.Evaluate(propertyClass3, propertyClass2, propertyClass1).Select(v => v.Value));
|
||||
var templateText = string.Concat(template.Evaluate(null, _propertyClass3, _propertyClass2, _propertyClass1).Select(v => v.Value));
|
||||
|
||||
templateText.Should().Be(outStr);
|
||||
|
||||
string formatInt(ITemplateTag templateTag, int value, string format)
|
||||
string FormatInt(ITemplateTag templateTag, int value, string? format, CultureInfo? culture)
|
||||
{
|
||||
if (int.TryParse(format, out var numDecs))
|
||||
return value.ToString($"D{numDecs}");
|
||||
return value.ToString();
|
||||
return value.ToString($"D{numDecs}", culture);
|
||||
return value.ToString(culture);
|
||||
}
|
||||
|
||||
string formatString(ITemplateTag templateTag, string? value, string formatString)
|
||||
string FormatString(ITemplateTag templateTag, string? value, string? format, CultureInfo? culture)
|
||||
{
|
||||
if (value is null) return string.Empty;
|
||||
else if (string.Compare(formatString, "u", ignoreCase: true) == 0) return value.ToUpper();
|
||||
else if (string.Compare(formatString, "l", ignoreCase: true) == 0) return value.ToLower();
|
||||
else return value;
|
||||
return CommonFormatters.StringFormatter(templateTag, value, format, culture);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,10 @@
|
||||
using AssertionHelper;
|
||||
using FileManager;
|
||||
using System;
|
||||
using AssertionHelper;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
|
||||
[assembly: Parallelize]
|
||||
|
||||
namespace FileUtilityTests;
|
||||
namespace FileManager.Tests;
|
||||
|
||||
[TestClass]
|
||||
public class GetSafePath
|
||||
@@ -37,8 +36,8 @@ public class GetSafePath
|
||||
[DataRow(@"/a///b/c///d.txt", @"/a/b/c/d.txt", PlatformID.Unix)]
|
||||
[DataRow(@"C:\""foo\<id>", @"C:\“foo\<id>", PlatformID.Win32NT)]
|
||||
[DataRow(@"/""foo/<id>", @"/“foo/<id>", PlatformID.Unix)]
|
||||
public void DefaultTests(string inStr, string outStr, PlatformID platformID)
|
||||
=> Test(inStr, outStr, Default, platformID);
|
||||
public void DefaultTests(string inStr, string outStr, PlatformID platformId)
|
||||
=> Test(inStr, outStr, Default, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// non-empty replacement
|
||||
@@ -60,8 +59,8 @@ public class GetSafePath
|
||||
[DataRow(@"/a///b/c///d.txt", @"/a/b/c/d.txt", PlatformID.Unix)]
|
||||
[DataRow(@"C:\""foo\<id>", @"C:\'foo\{id}", PlatformID.Win32NT)]
|
||||
[DataRow(@"/""foo/<id>", @"/""foo/<id>", PlatformID.Unix)]
|
||||
public void LoFiDefaultTests(string inStr, string outStr, PlatformID platformID)
|
||||
=> Test(inStr, outStr, LoFiDefault, platformID);
|
||||
public void LoFiDefaultTests(string inStr, string outStr, PlatformID platformId)
|
||||
=> Test(inStr, outStr, LoFiDefault, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// empty replacement
|
||||
@@ -83,12 +82,12 @@ public class GetSafePath
|
||||
[DataRow(@"/a///b/c///d.txt", @"/a/b/c/d.txt", PlatformID.Unix)]
|
||||
[DataRow(@"C:\""foo\<id>", @"C:\_foo\_id_", PlatformID.Win32NT)]
|
||||
[DataRow(@"/""foo/<id>", @"/""foo/<id>", PlatformID.Unix)]
|
||||
public void BarebonesDefaultTests(string inStr, string outStr, PlatformID platformID)
|
||||
=> Test(inStr, outStr, Barebones, platformID);
|
||||
public void BarebonesDefaultTests(string inStr, string outStr, PlatformID platformId)
|
||||
=> Test(inStr, outStr, Barebones, platformId);
|
||||
|
||||
private void Test(string inStr, string outStr, ReplacementCharacters replacements, PlatformID platformID)
|
||||
private void Test(string inStr, string outStr, ReplacementCharacters replacements, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
if (Environment.OSVersion.Platform == platformId)
|
||||
FileUtility.GetSafePath(inStr, replacements).PathWithoutPrefix.Should().Be(outStr);
|
||||
}
|
||||
}
|
||||
@@ -104,29 +103,29 @@ public class GetSafeFileName
|
||||
[TestMethod]
|
||||
[DataRow("http://test.com/a/b/c", "http_∕∕test.com∕a∕b∕c", PlatformID.Win32NT)]
|
||||
[DataRow("http://test.com/a/b/c", "http:∕∕test.com∕a∕b∕c", PlatformID.Unix)]
|
||||
public void url_null_replacement(string inStr, string outStr, PlatformID platformID) => DefaultReplacementTest(inStr, outStr, platformID);
|
||||
public void url_null_replacement(string inStr, string outStr, PlatformID platformId) => DefaultReplacementTest(inStr, outStr, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// empty replacement
|
||||
[DataRow("http://test.com/a/b/c", "http_∕∕test.com∕a∕b∕c", PlatformID.Win32NT)]
|
||||
[DataRow("http://test.com/a/b/c", "http:∕∕test.com∕a∕b∕c", PlatformID.Unix)]
|
||||
public void DefaultReplacementTest(string inStr, string outStr, PlatformID platformID) => Test(inStr, outStr, Default, platformID);
|
||||
public void DefaultReplacementTest(string inStr, string outStr, PlatformID platformId) => Test(inStr, outStr, Default, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// empty replacement
|
||||
[DataRow("http://test.com/a/b/c", "http-__test.com_a_b_c", PlatformID.Win32NT)]
|
||||
[DataRow("http://test.com/a/b/c", "http:__test.com_a_b_c", PlatformID.Unix)]
|
||||
public void LoFiDefaultReplacementTest(string inStr, string outStr, PlatformID platformID) => Test(inStr, outStr, LoFiDefault, platformID);
|
||||
public void LoFiDefaultReplacementTest(string inStr, string outStr, PlatformID platformId) => Test(inStr, outStr, LoFiDefault, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// empty replacement
|
||||
[DataRow("http://test.com/a/b/c", "http___test.com_a_b_c", PlatformID.Win32NT)]
|
||||
[DataRow("http://test.com/a/b/c", "http:__test.com_a_b_c", PlatformID.Unix)]
|
||||
public void BarebonesDefaultReplacementTest(string inStr, string outStr, PlatformID platformID) => Test(inStr, outStr, Barebones, platformID);
|
||||
public void BarebonesDefaultReplacementTest(string inStr, string outStr, PlatformID platformId) => Test(inStr, outStr, Barebones, platformId);
|
||||
|
||||
private void Test(string inStr, string outStr, ReplacementCharacters replacements, PlatformID platformID)
|
||||
private void Test(string inStr, string outStr, ReplacementCharacters replacements, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
if (Environment.OSVersion.Platform == platformId)
|
||||
replacements.ReplaceFilenameChars(inStr).Should().Be(outStr);
|
||||
}
|
||||
}
|
||||
@@ -200,7 +199,7 @@ public class GetValidFilename
|
||||
// dot-folders
|
||||
[DataRow(@"C:\a bc\.x y z\f i l e.txt", "txt", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a bc/.x y z/f i l e.txt", "txt", PlatformID.Unix)]
|
||||
public void Valid(string input, string extension, PlatformID platformID) => Tests(input, extension, input, platformID);
|
||||
public void Valid(string input, string extension, PlatformID platformId) => Tests(input, extension, input, platformId);
|
||||
|
||||
[TestMethod]
|
||||
// folder spaces
|
||||
@@ -215,9 +214,9 @@ public class GetValidFilename
|
||||
// file end dots
|
||||
[DataRow(@"C:\a bc\x y z\f i l e.txt . . .", "txt", @"C:\a bc\x y z\f i l e.txt", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a bc/x y z/f i l e.txt . . .", "txt", @"/a bc/x y z/f i l e.txt", PlatformID.Unix)]
|
||||
public void Tests(string input, string extension, string expected, PlatformID platformID)
|
||||
public void Tests(string input, string extension, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
if (Environment.OSVersion.Platform == platformId)
|
||||
FileUtility.GetValidFilename(input, Replacements, extension).PathWithoutPrefix.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
@@ -229,7 +228,7 @@ public class RemoveLastCharacter
|
||||
public void is_null() => Tests(null!, null);
|
||||
|
||||
[TestMethod]
|
||||
public void empty() => Tests("", "");
|
||||
public void Empty() => Tests("", "");
|
||||
|
||||
[TestMethod]
|
||||
public void single_space() => Tests(" ", "");
|
||||
|
||||
@@ -6,6 +6,7 @@ using LibationFileManager.Templates;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using static TemplatesTests.Shared;
|
||||
@@ -43,9 +44,9 @@ namespace TemplatesTests
|
||||
AudibleProductId = "asin",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
YearPublished = 2017,
|
||||
YearPublished = null, // explicitly null
|
||||
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
|
||||
Narrators = [new("Stephen Fry", "B000APAGVS"), new("Some Narrator", "B000000000")],
|
||||
Narrators = [], // explicitly empty list
|
||||
Series = series,
|
||||
BitRate = 128,
|
||||
SampleRate = 44100,
|
||||
@@ -53,9 +54,12 @@ namespace TemplatesTests
|
||||
Language = "English",
|
||||
Subtitle = "An Audible Original Drama",
|
||||
TitleWithSubtitle = "A Study in Scarlet: An Audible Original Drama",
|
||||
Codec = "AAC-LC",
|
||||
FileVersion = "1.0",
|
||||
LibationVersion = "1.0.0",
|
||||
Codec = @"AAC[LC]\MP3", // special chars added
|
||||
FileVersion = null, // explicitly null
|
||||
LibationVersion = "", // explicitly empty string
|
||||
LengthInMinutes = TimeSpan.FromMinutes(100),
|
||||
IsAbridged = true,
|
||||
Tags = [new StringDto("Tag1"), new StringDto("Tag2"), new StringDto("Tag3")],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -75,7 +79,7 @@ namespace TemplatesTests
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
public class getFileNamingTemplate
|
||||
public class GetFileNamingTemplate
|
||||
{
|
||||
static readonly ReplacementCharacters Replacements = ReplacementCharacters.Default(Environment.OSVersion.Platform == PlatformID.Win32NT);
|
||||
|
||||
@@ -102,8 +106,8 @@ namespace TemplatesTests
|
||||
[DataRow("f", @"C:\foo\bar", ".ext", @"C:\foo\bar\f.ext")]
|
||||
[DataRow("<id>", @"C:\foo\bar", ".ext", @"C:\foo\bar\asin.ext")]
|
||||
[DataRow("<bitrate> - <samplerate> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\128 - 44100 - 2.ext")]
|
||||
[DataRow("<year> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\2017 - 2.ext")]
|
||||
[DataRow("(000.0) <year> - <channels>", @"C:\foo\bar", "ext", @"C:\foo\bar\(000.0) 2017 - 2.ext")]
|
||||
[DataRow("<year> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\- 2.ext")]
|
||||
[DataRow("(000.0) <year> - <channels>", @"C:\foo\bar", "ext", @"C:\foo\bar\(000.0) - 2.ext")]
|
||||
public void Tests(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
@@ -115,7 +119,7 @@ namespace TemplatesTests
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -151,7 +155,7 @@ namespace TemplatesTests
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, replacements)
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: null, replacements: replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -159,15 +163,62 @@ namespace TemplatesTests
|
||||
[TestMethod]
|
||||
[DataRow("<bitrate>Kbps <samplerate>Hz", "128Kbps 44100Hz")]
|
||||
[DataRow("<bitrate>Kbps <samplerate[6]>Hz", "128Kbps 044100Hz")]
|
||||
[DataRow("<bitrate[4]>Kbps <samplerate>Hz", "0128Kbps 44100Hz")]
|
||||
[DataRow("<bitrate[4]>Kbps <titleshort[u]>", "0128Kbps A STUDY IN SCARLET")]
|
||||
[DataRow("<bitrate[1]>Kbps <samplerate>Hz", "128Kbps 44100Hz")]
|
||||
[DataRow("<bitrate[2]>Kbps <titleshort[u]>", "128Kbps A STUDY IN SCARLET")]
|
||||
[DataRow("<bitrate[3]>Kbps <titleshort[t]>", "128Kbps A Study In Scarlet")]
|
||||
[DataRow("<bitrate[4]>Kbps <titleshort[l]>", "0128Kbps a study in scarlet")]
|
||||
[DataRow("<bitrate[4]>Kbps <samplerate[6]>Hz", "0128Kbps 044100Hz")]
|
||||
[DataRow(@"<bitrate[00'['0\#0']']>Kbps <titleshort[T]>", "01[2#8]Kbps A Study In Scarlet")]
|
||||
[DataRow("<codec[7t]> <samplerate[6]>Hz", "Aac[Lc] 044100Hz")]
|
||||
[DataRow("<codec[3T]> <titleshort[ 5 U ]>", "AAC A STU")]
|
||||
[DataRow("<bitrate [ 4 ] >Kbps <samplerate [ 6 ] >Hz", "0128Kbps 044100Hz")]
|
||||
public void FormatTags(string template, string expected)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate.GetFilename(GetLibraryBook(), "", "", Replacements).PathWithoutPrefix.Should().Be(expected);
|
||||
fileTemplate.GetFilename(GetLibraryBook(), "", "", culture: null, replacements: Replacements).PathWithoutPrefix.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<narrator>", "")]
|
||||
[DataRow("<narrator[format({L})]>", "")]
|
||||
[DataRow("<first narrator>", "")]
|
||||
[DataRow("<file version>", "")]
|
||||
[DataRow("<libation version>", "")]
|
||||
[DataRow("<year>", "")]
|
||||
public void EmptyFields(string template, string expected)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<minutes>", 100, "100")]
|
||||
[DataRow("<minutes[M]>", 100, "100")]
|
||||
[DataRow("<minutes[MM]>", 100, "100")]
|
||||
[DataRow(@"<minutes[H\-m]>", 100, "1-40")]
|
||||
[DataRow(@"<minutes[hh\-MM]>", 100, "01-100")]
|
||||
[DataRow(@"<minutes[%m\ m\ mm]>", 100, "40 40 40")]
|
||||
[DataRow(@"<minutes[\%M\ M\ MM]>", 100, "%0 1 00")]
|
||||
[DataRow(@"<minutes[D\.hh\-MM]>", 100, "0.01-100")]
|
||||
[DataRow(@"<minutes[dd\dhh\hmm\m]>", 100, "00d01h40m")]
|
||||
[DataRow("""<minutes[d'[days], 'h"(hours), "m'{minutes}']>""", 100, "0[days], 1(hours), 40{minutes}")]
|
||||
[DataRow(@"<minutes[H\-M]>", 2000, "33-20")]
|
||||
[DataRow(@"<minutes[DDD\-HHH\-MMM]>", 2000, "001-009-020")]
|
||||
[DataRow(@"<minutes[M\-H\-D]>", 2000, "20-9-1")]
|
||||
[DataRow(@"<minutes[D\-M]>", 100, "0-100")]
|
||||
[DataRow(@"<minutes[D\-M]>", 1500, "1-60")]
|
||||
[DataRow(@"<minutes[D\-M]>", 2000, "1-560")]
|
||||
[DataRow(@"<minutes[D\-M]>", 2880, "2-0")]
|
||||
[DataRow(@"<minutes[DD\-MM]>", 1500, "01-60")]
|
||||
[DataRow(@"<minutes[D\-MMM'{'MM\}]>", 2000, "1-005{60}")]
|
||||
public void MinutesFormat(string template, int minutes, string expected)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
bookDto.LengthInMinutes = TimeSpan.FromMinutes(minutes);
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate.GetFilename(bookDto, "", "", Replacements).PathWithoutPrefix.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -192,7 +243,7 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -221,7 +272,7 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -240,7 +291,7 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -252,18 +303,17 @@ namespace TemplatesTests
|
||||
[DataRow("<id> - <filedate[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 01∕28∕23 00:00.m4b", PlatformID.Unix)]
|
||||
[DataRow("<id> - <date added[MM/dd/yy HH:mm]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\asin - 06∕09∕22 00_00.m4b", PlatformID.Win32NT)]
|
||||
[DataRow("<id> - <date added[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 06∕09∕22 00:00.m4b", PlatformID.Unix)]
|
||||
public void DateFormat_illegal(string template, string dirFullPath, string extension, string expected, PlatformID platformID)
|
||||
public void DateFormat_illegal(string template, string dirFullPath, string extension, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
if (Environment.OSVersion.Platform != platformId) Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
fileTemplate.HasWarnings.Should().BeFalse();
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate.HasWarnings.Should().BeFalse();
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, culture: CultureInfo.InvariantCulture, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -282,7 +332,7 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(lbDto, dirFullPath, extension, Replacements)
|
||||
.GetFilename(lbDto, dirFullPath, extension, culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -323,31 +373,38 @@ namespace TemplatesTests
|
||||
bookDto.Authors = [new(author, null)];
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("<author[format(Title={T}, First={F}, Middle={M} Last={L}, Suffix={S})]>", out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, "", "", Replacements)
|
||||
.GetFilename(bookDto, "", "", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<author>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[sort(F)]>", "Charles E. Gannon, Christopher John Fetherolf, Jill Conner Browne, Jon Bon Jovi, Lucy Maud Montgomery, Paul Van Doren")]
|
||||
[DataRow("<author[sort(L)]>", "Jon Bon Jovi, Jill Conner Browne, Christopher John Fetherolf, Charles E. Gannon, Lucy Maud Montgomery, Paul Van Doren")]
|
||||
[DataRow("<author[sort(M)]>", "Jon Bon Jovi, Paul Van Doren, Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery")]
|
||||
[DataRow("<author[sort(f)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[sort(m)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[sort(l)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[sort(F)]>", "Charles E. Gannon, Christopher John Fetherolf, Emma Gannon, Jill Conner Browne, Jon Bon Jovi, Lucy Maud Montgomery, Paul Van Doren")]
|
||||
[DataRow("<author[sort(M)]>", "Jon Bon Jovi, Paul Van Doren, Emma Gannon, Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery")]
|
||||
[DataRow("<author[sort(L)]>", "Jon Bon Jovi, Jill Conner Browne, Christopher John Fetherolf, Charles E. Gannon, Emma Gannon, Lucy Maud Montgomery, Paul Van Doren")]
|
||||
[DataRow("<author[sort(f)]>", "Paul Van Doren, Lucy Maud Montgomery, Jon Bon Jovi, Jill Conner Browne, Emma Gannon, Christopher John Fetherolf, Charles E. Gannon")]
|
||||
[DataRow("<author[sort(m)]>", "Lucy Maud Montgomery, Christopher John Fetherolf, Charles E. Gannon, Jill Conner Browne, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[sort(l)]>", "Paul Van Doren, Lucy Maud Montgomery, Charles E. Gannon, Emma Gannon, Christopher John Fetherolf, Jill Conner Browne, Jon Bon Jovi")]
|
||||
[DataRow("<author [ max( 1 ) ]>", "Jill Conner Browne")]
|
||||
[DataRow("<author[max(2)]>", "Jill Conner Browne, Charles E. Gannon")]
|
||||
[DataRow("<author[max(3)]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf")]
|
||||
[DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul")]
|
||||
[DataRow("<author[format({L}, {F} {ID})]>", "Browne, Jill B1, Gannon, Charles B2, Fetherolf, Christopher B3, Montgomery, Lucy B4, Bon Jovi, Jon B5, Van Doren, Paul B6")]
|
||||
[DataRow("<author[format({ID})]>", "B1, B2, B3, B4, B5, B6")]
|
||||
[DataRow("<author[format({Id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[format({iD})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[format({id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[format({f}, {l})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[format(First={F}, Last={L})]>", "First=Jill, Last=Browne, First=Charles, Last=Gannon, First=Christopher, Last=Fetherolf, First=Lucy, Last=Montgomery, First=Jon, Last=Bon Jovi, First=Paul, Last=Van Doren")]
|
||||
[DataRow("<author[slice(3)]>", "Christopher John Fetherolf")]
|
||||
[DataRow("<author[slice(3...5)]>", "Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi")]
|
||||
[DataRow("<author[slice(-2)]>", "Paul Van Doren")]
|
||||
[DataRow("<author[slice(-3..-2)]>", "Jon Bon Jovi, Paul Van Doren")]
|
||||
[DataRow("<author[sort(LF) slice(4..5)]>", "Charles E. Gannon, Emma Gannon")]
|
||||
[DataRow("<author[sort(Lf) slice(4..5)]>", "Emma Gannon, Charles E. Gannon")]
|
||||
[DataRow("<author[format({L}, {F})]>", "Browne, Jill, Gannon, Charles, Fetherolf, Christopher, Montgomery, Lucy, Bon Jovi, Jon, Van Doren, Paul, Gannon, Emma")]
|
||||
[DataRow("<author[format({L}, {F} {ID})]>", "Browne, Jill B1, Gannon, Charles B2, Fetherolf, Christopher B3, Montgomery, Lucy B4, Bon Jovi, Jon B5, Van Doren, Paul B6, Gannon, Emma B7")]
|
||||
[DataRow("<author[format({ID})]>", "B1, B2, B3, B4, B5, B6, B7")]
|
||||
[DataRow("<author[format({Id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[format({iD})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[format({id})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[format({f}, {l})]>", "Jill Conner Browne, Charles E. Gannon, Christopher John Fetherolf, Lucy Maud Montgomery, Jon Bon Jovi, Paul Van Doren, Emma Gannon")]
|
||||
[DataRow("<author[format(First={F}, Last={L})]>",
|
||||
"First=Jill, Last=Browne, First=Charles, Last=Gannon, First=Christopher, Last=Fetherolf, First=Lucy, Last=Montgomery, First=Jon, Last=Bon Jovi, First=Paul, Last=Van Doren, First=Emma, Last=Gannon")]
|
||||
[DataRow("<author[format({L}, {F}) separator( - ) max(3)]>", "Browne, Jill - Gannon, Charles - Fetherolf, Christopher")]
|
||||
[DataRow("<author[sort(F) max(2) separator(; ) format({F})]>", "Charles; Christopher")]
|
||||
[DataRow("<author[sort(L) max(2) separator(; ) format({L})]>", "Bon Jovi; Browne")]
|
||||
@@ -367,26 +424,89 @@ namespace TemplatesTests
|
||||
new("Christopher John Fetherolf", "B3"),
|
||||
new("Lucy Maud Montgomery", "B4"),
|
||||
new("Jon Bon Jovi", "B5"),
|
||||
new("Paul Van Doren", "B6")
|
||||
new("Paul Van Doren", "B6"),
|
||||
new("Emma Gannon", "B7"),
|
||||
];
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, "", "", Replacements)
|
||||
.GetFilename(bookDto, "", "", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<has libation version->empty-string<-has>", "")]
|
||||
[DataRow("<!has libation version->empty-string<-has>", "empty-string")]
|
||||
[DataRow("<is libation version[=foobar]->empty-string<-is>", "")]
|
||||
[DataRow("<!is libation version[=foobar]->empty-string<-is>", "empty-string")]
|
||||
[DataRow("<is libation version[=]->empty-string<-is>", "empty-string")]
|
||||
[DataRow("<is libation version[#=0]->empty-string<-is>", "empty-string")]
|
||||
[DataRow("<is libation version[]->empty-string<-is>", "empty-string")]
|
||||
[DataRow("<has file version->null-string<-has>", "")]
|
||||
[DataRow("<!has file version->null-string<-has>", "null-string")]
|
||||
[DataRow("<is file version[=foobar]->null-string<-is>", "")]
|
||||
[DataRow("<is file version[=]->null-string<-is>", "")]
|
||||
[DataRow("<!is file version[=]->null-string<-is>", "null-string")]
|
||||
[DataRow("<is file version[#=0]->null-string<-is>", "")]
|
||||
[DataRow("<is file version[]->null-string<-is>", "")]
|
||||
[DataRow("<has year->null-int<-has>", "")]
|
||||
[DataRow("<is year[=]->null-int<-is>", "")]
|
||||
[DataRow("<is year[#=0]->null-int<-is>", "")]
|
||||
[DataRow("<is year[0]->null-int<-is>", "")]
|
||||
[DataRow("<!is year[0]->null-int<-is>", "null-int")]
|
||||
[DataRow("<is year[]->null-int<-is>", "")]
|
||||
[DataRow("<has FAKE->unknown-tag<-has>", "")]
|
||||
[DataRow("<is FAKE[=]->unknown-tag<-is>", "")]
|
||||
[DataRow("<!is FAKE[=]->unknown-tag<-is>", "unknown-tag")]
|
||||
[DataRow("<is FAKE[=foobar]->unknown-tag<-is>", "")]
|
||||
[DataRow("<is FAKE[#=0]->unknown-tag<-is>", "")]
|
||||
[DataRow("<is FAKE[]->unknown-tag<-is>", "")]
|
||||
[DataRow("<has narrator->empty-list<-has>", "")]
|
||||
[DataRow("<is narrator[=foobar]->empty-list<-is>", "")]
|
||||
[DataRow("<!is narrator[=foobar]->empty-list<-is>", "empty-list")]
|
||||
[DataRow("<is narrator[!=foobar]->empty-list<-is>", "")]
|
||||
[DataRow("<!is narrator[!=foobar]->empty-list<-is>", "empty-list")]
|
||||
[DataRow("<is narrator[=]->empty-list<-is>", "")]
|
||||
[DataRow("<is narrator[~.*]->empty-list<-is>", "")]
|
||||
[DataRow("<is narrator[<1]->empty-list<-is>", "empty-list")]
|
||||
[DataRow("<is narrator[#=0]->empty-list<-is>", "empty-list")]
|
||||
[DataRow("<is narrator[]->empty-list<-is>", "")]
|
||||
[DataRow("<is first narrator->no-first<-is>", "")]
|
||||
[DataRow("<is first narrator[=foobar]->no-first<-is>", "")]
|
||||
[DataRow("<is first narrator[=]->no-first<-is>", "")]
|
||||
[DataRow("<is first narrator[#=0]->no-first<-is>", "")]
|
||||
[DataRow("<is first narrator[]->no-first<-is>", "")]
|
||||
public void HasValue_on_empty_test(string template, string expected)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
var multiDto = new MultiConvertFileProperties
|
||||
{
|
||||
PartsPosition = 1,
|
||||
PartsTotal = 2,
|
||||
Title = bookDto.Title,
|
||||
OutputFileName = "outputfile.m4b"
|
||||
};
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, multiDto, "", "", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
fileTemplate.Errors.Should().HaveCount(0);
|
||||
fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: <title>"
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<has id->true<-has>", "true")]
|
||||
[DataRow("<!has id->false<-has>", "")]
|
||||
[DataRow("<has title->true<-has>", "true")]
|
||||
[DataRow("<has title short->true<-has>", "true")]
|
||||
[DataRow("<has audible title->true<-has>", "true")]
|
||||
[DataRow("<has audible subtitle->true<-has>", "true")]
|
||||
[DataRow("<has author->true<-has>", "true")]
|
||||
[DataRow("<!has author->false<-has>", "")]
|
||||
[DataRow("<has first author->true<-has>", "true")]
|
||||
[DataRow("<has narrator->true<-has>", "true")]
|
||||
[DataRow("<has first narrator->true<-has>", "true")]
|
||||
[DataRow("<has series->true<-has>", "true")]
|
||||
[DataRow("<has first series->true<-has>", "true")]
|
||||
[DataRow("<has series#->true<-has>", "true")]
|
||||
@@ -394,22 +514,68 @@ namespace TemplatesTests
|
||||
[DataRow("<has samplerate->true<-has>", "true")]
|
||||
[DataRow("<has channels->true<-has>", "true")]
|
||||
[DataRow("<has codec->true<-has>", "true")]
|
||||
[DataRow("<has file version->true<-has>", "true")]
|
||||
[DataRow("<has libation version->true<-has>", "true")]
|
||||
[DataRow(@"<is codec[=aac\[lc\]\\mp3]->true<-is>", "true")]
|
||||
[DataRow(@"<is codec[=aac\[lc\]\\mp4]->true<-is>", "")]
|
||||
[DataRow("<has account->true<-has>", "true")]
|
||||
[DataRow("<has account nickname->true<-has>", "true")]
|
||||
[DataRow("<has locale->true<-has>", "true")]
|
||||
[DataRow("<has year->true<-has>", "true")]
|
||||
[DataRow("<has language->true<-has>", "true")]
|
||||
[DataRow("<has language short->true<-has>", "true")]
|
||||
[DataRow("<has file date->true<-has>", "true")]
|
||||
[DataRow("<has pub date->true<-has>", "true")]
|
||||
[DataRow("<has date added->true<-has>", "true")]
|
||||
[DataRow("<has tag->true<-has>", "true")]
|
||||
[DataRow("<has first tag->true<-has>", "true")]
|
||||
[DataRow("<!has first tag->false<-has>", "")]
|
||||
[DataRow("<has ch count->true<-has>", "true")]
|
||||
[DataRow("<has ch title->true<-has>", "true")]
|
||||
[DataRow("<has ch#->true<-has>", "true")]
|
||||
[DataRow("<has ch# 0->true<-has>", "true")]
|
||||
[DataRow("<has FAKE->true<-has>", "")]
|
||||
[DataRow("<is title[=A Study in Scarlet: An Audible Original Drama]->true<-is>", "true")]
|
||||
[DataRow("<!is title[=A Study in Scarlet: An Audible Original Drama]->false<-is>", "")]
|
||||
[DataRow("<is title[U][=A STUDY IN SCARLET: AN AUDIBLE ORIGINAL DRAMA]->true<-is>", "true")]
|
||||
[DataRow("<is title[#=45]->true<-is>", "true")]
|
||||
[DataRow("<is title[!=foo]->true<-is>", "true")]
|
||||
[DataRow("<!is title[!=foo]->false<-is>", "")]
|
||||
[DataRow("<is title[~A Study.*]->true<-is>", "true")]
|
||||
[DataRow("<is title[foo]->true<-is>", "")]
|
||||
[DataRow("<is ch count[>=1]->true<-is>", "true")]
|
||||
[DataRow("<is ch count[>1]->true<-is>", "true")]
|
||||
[DataRow("<is ch count[<=100]->true<-is>", "true")]
|
||||
[DataRow("<is ch count[<100]->true<-is>", "true")]
|
||||
[DataRow("<is ch count[=2]->true<-is>", "true")]
|
||||
[DataRow("<is author[>=2]->true<-is>", "true")]
|
||||
[DataRow("<is author[#=2]->true<-is>", "true")]
|
||||
[DataRow("<is author[=Arthur Conan Doyle]->true<-is>", "true")]
|
||||
[DataRow("<is author[format({L})][=Doyle]->true<-is>", "true")]
|
||||
[DataRow("<!is author[format({L})][=Doyle]->false<-is>", "")]
|
||||
[DataRow("<is author[format({L})][!=Doyle]->true<-is>", "true")]
|
||||
[DataRow("<!is author[format({L})][!=Doyle]->false<-is>", "")]
|
||||
[DataRow("<is author[format({L})separator(:)][=Doyle:Fry]->true<-is>", "true")]
|
||||
[DataRow("<is author[>=3]->true<-is>", "")]
|
||||
[DataRow(@"<is author[slice(99)][~.\*]->true<-is>", "")]
|
||||
[DataRow("<is author[slice(99)separator(:)][~.*]->true<-is>", "")]
|
||||
[DataRow("<is author[slice(-9)separator(:)][~.*]->true<-is>", "")]
|
||||
[DataRow("<is author[slice(2..1)separator(:)][~.*]->true<-is>", "")]
|
||||
[DataRow("<is author[slice(-1..1)separator(:)][~.*]->true<-is>", "")]
|
||||
[DataRow("<is author[slice(-1..-2)separator(:)][~.*]->true<-is>", "")]
|
||||
[DataRow("<is author[=Sherlock]->true<-is>", "")]
|
||||
[DataRow("<!is author[=Sherlock]->false<-is>", "false")]
|
||||
[DataRow("<is author[!=Sherlock]->true<-is>", "true")]
|
||||
[DataRow("<!is author[!=Sherlock]->false<-is>", "")]
|
||||
[DataRow("<is tag[=Tag1]->true<-is>", "true")]
|
||||
[DataRow("<is tag[separator(:)slice(-2..)][=Tag2:Tag3]->true<-is>", "true")]
|
||||
[DataRow("<is audible subtitle[3][=an]->false<-is>", "")]
|
||||
[DataRow("<is audible subtitle[3][=an ]->true<-is>", "true")]
|
||||
[DataRow(@"<is audible subtitle[3][=an\ ]->true<-is>", "true")]
|
||||
[DataRow("<is audible subtitle[3][= an]->false<-is>", "")]
|
||||
[DataRow("<is audible subtitle[3][= an ]->false<-is>", "")]
|
||||
[DataRow(@"<is audible subtitle[3][= an\ ]->false<-is>", "")]
|
||||
[DataRow(@"<is audible subtitle[3][=\ an\ ]->false<-is>", "")]
|
||||
[DataRow("<is audible subtitle[3][ =an]->false<-is>", "")]
|
||||
[DataRow("<is audible subtitle[3][ =an ]->true<-is>", "true")]
|
||||
[DataRow(@"<is audible subtitle[3][ =an\ ]->true<-is>", "true")]
|
||||
[DataRow(@"<is minutes[>42]->true<-is>", "true")]
|
||||
public void HasValue_test(string template, string expected)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
@@ -423,14 +589,17 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, multiDto, "", "", Replacements)
|
||||
.GetFilename(bookDto, multiDto, "", "", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
fileTemplate.Errors.Should().HaveCount(0);
|
||||
fileTemplate.Warnings.Should().HaveCount(1); // "Should use tags. Eg: <title>"
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<series>", "Series A, Series B, Series C, Series D")]
|
||||
[DataRow("<series[]>", "Series A, Series B, Series C, Series D")]
|
||||
[DataRow("<series[slice(2..3)]>", "Series B, Series C")]
|
||||
[DataRow("<series[max(1)]>", "Series A")]
|
||||
[DataRow("<series[max(2)]>", "Series A, Series B")]
|
||||
[DataRow("<series[max(3)]>", "Series A, Series B, Series C")]
|
||||
@@ -441,21 +610,21 @@ namespace TemplatesTests
|
||||
[DataRow("<first series>", "Series A")]
|
||||
[DataRow("<first series[]>", "Series A")]
|
||||
[DataRow("<first series[{N}, {#}, {ID}]>", "Series A, 1, B1")]
|
||||
[DataRow("<first series[{N}, {#:00.0}]>", "Series A, 01.0")]
|
||||
[DataRow("<first series[{N}, {#:0'{}'0.0}]>", "Series A, 0{}1.0")]
|
||||
public void SeriesFormat_formatters(string template, string expected)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
bookDto.Series =
|
||||
[
|
||||
new("Series A", "1", "B1"),
|
||||
new("Series B", "6", "B2"),
|
||||
new("Series C", "2", "B3"),
|
||||
new("Series D", "1-5", "B4"),
|
||||
new("Series A", "1", "B1"),
|
||||
new("Series B", "6", "B2"),
|
||||
new("Series C", "2", "B3"),
|
||||
new("Series D", "1-5", "B4"),
|
||||
];
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, "", "", Replacements)
|
||||
.GetFilename(bookDto, "", "", culture: CultureInfo.InvariantCulture, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -480,7 +649,7 @@ namespace TemplatesTests
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
fileTemplate
|
||||
.GetFilename(bookDto, "", "", Replacements)
|
||||
.GetFilename(bookDto, "", "", culture: CultureInfo.InvariantCulture, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -488,48 +657,109 @@ namespace TemplatesTests
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)]
|
||||
public void IfSeries_empty(string directory, string expected, PlatformID platformID)
|
||||
public void IfSeries_empty(string directory, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series-><-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS {platformId}.");
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), directory, "ext", Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series-><-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)]
|
||||
public void IfSeries_no_series(string directory, string expected, PlatformID platformID)
|
||||
public void IfSeries_no_series(string directory, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", Replacements)
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate.GetFilename(GetLibraryBook(null), directory, "ext", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\a\b", @"C:\a\b\foo-Sherlock Holmes-asin-bar.ext", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a/b", @"/a/b/foo-Sherlock Holmes-asin-bar.ext", PlatformID.Unix)]
|
||||
public void IfSeries_with_series(string directory, string expected, PlatformID platformID)
|
||||
public void IfSeries_with_series(string directory, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), directory, "ext", Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>("foo<if series->-<series>-<id>-<-if series>bar", out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), directory, "ext", culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<if abridged->Abridged<-if abridged>", "Abridged", true)]
|
||||
[DataRow("<if abridged->Abridged<-if abridged>", "", false)]
|
||||
public void IfAbridged_test(string template, string expected, bool isAbridged)
|
||||
{
|
||||
var bookDto = GetLibraryBook();
|
||||
bookDto.IsAbridged = isAbridged;
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty })
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<audibletitle [u]>", "I", "en-US", "i")]
|
||||
[DataRow("<audibletitle [l]>", "ı", "tr-TR", "I")]
|
||||
[DataRow("<audibletitle [u]>", "İ", "tr-TR", "i")]
|
||||
[DataRow(@"<minutes[D,DDD.DDE-0\-H,HHH.HH\-#,##M.##]>", "8.573,30E1-0.021,00-9", "es-ES", "any")]
|
||||
[DataRow(@"<minutes[D,DDD.DDE-0\-H,HHH.HH\-#,##M.##]>", "8,573.30E1-0,021.00-9", "en-AU", "any")]
|
||||
[DataRow("<samplerate[#,##0'Hz ']>", "44,100Hz ", "en-CA", "any")]
|
||||
[DataRow("<samplerate[#,##0'Hz ']>", "44’100Hz ", "de-CH", "any")]
|
||||
[DataRow("<samplerate[#,##0'Hz ']>", "44\u00A0100Hz ", "fr-CA", "any")] // non-breaking-space
|
||||
public void Tag_culture_test(string template, string expected, string cultureName, string title)
|
||||
{
|
||||
var bookDto = Shared.GetLibraryBook();
|
||||
bookDto.Title = title;
|
||||
bookDto.LengthInMinutes = TimeSpan.FromMinutes(123456789);
|
||||
var culture = new CultureInfo(cultureName);
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty }, culture)
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<tag>", "Tag1, Tag2, Tag3")]
|
||||
[DataRow("<tag [separator( - )]>", "Tag1 - Tag2 - Tag3")]
|
||||
[DataRow("<tag [format({S:u})]>", "TAG1, TAG2, TAG3")]
|
||||
[DataRow("<tag[format({S:l})]>", "tag1, tag2, tag3")]
|
||||
[DataRow("<tag[format(Tag: {S})]>", "Tag: Tag1, Tag: Tag2, Tag: Tag3")]
|
||||
[DataRow("<tag [max(1)]>", "Tag1")]
|
||||
[DataRow("<tag [slice(2..)]>", "Tag2, Tag3")]
|
||||
[DataRow("<tag[sort(s)]>", "Tag3, Tag2, Tag1")]
|
||||
[DataRow("<first tag>", "Tag1")]
|
||||
[DataRow("<first tag[]>", "Tag1")]
|
||||
[DataRow("<first tag[l]>", "tag1")]
|
||||
public void Tag_test(string template, string expected)
|
||||
{
|
||||
var bookDto = Shared.GetLibraryBook();
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetName(bookDto, new MultiConvertFileProperties { OutputFileName = string.Empty })
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -552,10 +782,10 @@ namespace Templates_Other
|
||||
[DataRow("/foo/bar", "/Folder/<title> <title> <title> <title> <title> <title> <title> <title> <title> [<id>]", @"/foo/bar/Folder/my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 0000000000000000 my: book 00000000000000000 my: book 00000000000000000 [ID123456].txt", PlatformID.Unix)]
|
||||
[DataRow(@"C:\foo\bar", @"\<title>\<title> [<id>]", @"C:\foo\bar\my_ book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000\my_ book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Win32NT)]
|
||||
[DataRow("/foo/bar", @"/<title>/<title> [<id>]", "/foo/bar/my: book 000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000/my: book 0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000 [ID123456].txt", PlatformID.Unix)]
|
||||
public void Test_trim_to_max_path(string dirFullPath, string template, string expected, PlatformID platformID)
|
||||
public void Test_trim_to_max_path(string dirFullPath, string template, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform != platformID)
|
||||
return;
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append('0', 300);
|
||||
@@ -570,7 +800,7 @@ namespace Templates_Other
|
||||
public void Test_windows_relative_path_too_long(string baseDir, string template)
|
||||
{
|
||||
if (Environment.OSVersion.Platform != PlatformID.Win32NT)
|
||||
return;
|
||||
Assert.Inconclusive($"Skipped because OS is not {PlatformID.Win32NT}.");
|
||||
|
||||
var sb = new System.Text.StringBuilder();
|
||||
sb.Append('0', 300);
|
||||
@@ -578,13 +808,6 @@ namespace Templates_Other
|
||||
Assert.ThrowsExactly<PathTooLongException>(() => NEW_GetValidFilename_FileNamingTemplate(baseDir, template, "my: book " + longText, "txt"));
|
||||
}
|
||||
|
||||
private class TemplateTag : ITemplateTag
|
||||
{
|
||||
public required string TagName { get; init; }
|
||||
public string? DefaultValue { get; }
|
||||
public string? Description { get; }
|
||||
public string? Display { get; }
|
||||
}
|
||||
private static string NEW_GetValidFilename_FileNamingTemplate(string dirFullPath, string template, string title, string extension)
|
||||
{
|
||||
extension = FileUtility.GetStandardizedExtension(extension);
|
||||
@@ -595,16 +818,18 @@ namespace Templates_Other
|
||||
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var fileNamingTemplate).Should().BeTrue();
|
||||
|
||||
return fileNamingTemplate.GetFilename(lbDto, dirFullPath, extension, Replacements).PathWithoutPrefix;
|
||||
return fileNamingTemplate.GetFilename(lbDto, dirFullPath, extension, culture: null, replacements: Replacements).PathWithoutPrefix;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\foo\bar\my file.txt", @"C:\foo\bar\my file - 002 - title.txt", PlatformID.Win32NT)]
|
||||
[DataRow(@"/foo/bar/my file.txt", @"/foo/bar/my file - 002 - title.txt", PlatformID.Unix)]
|
||||
public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformID)
|
||||
public void equiv_GetMultipartFileName(string inStr, string outStr, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr);
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
NEW_GetMultipartFileName_FileNamingTemplate(inStr, 2, 100, "title").Should().Be(outStr);
|
||||
}
|
||||
|
||||
private static string NEW_GetMultipartFileName_FileNamingTemplate(string originalPath, int partsPosition, int partsTotal, string suffix)
|
||||
@@ -623,27 +848,28 @@ namespace Templates_Other
|
||||
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate).Should().BeTrue();
|
||||
|
||||
return chapterFileTemplate
|
||||
.GetFilename(lbDto, new MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition, OutputFileName = string.Empty }, dir, estension, Replacements)
|
||||
.GetFilename(lbDto, new MultiConvertFileProperties { Title = suffix, PartsTotal = partsTotal, PartsPosition = partsPosition, OutputFileName = string.Empty }, dir, estension,
|
||||
culture: null, replacements: Replacements)
|
||||
.PathWithoutPrefix;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"\foo\<title>.txt", @"\foo\sl∕as∕he∕s.txt", PlatformID.Win32NT)]
|
||||
[DataRow(@"/foo/<title>.txt", @"/foo/s\l∕a\s∕h\e∕s.txt", PlatformID.Unix)]
|
||||
public void remove_slashes(string inStr, string outStr, PlatformID platformID)
|
||||
public void remove_slashes(string inStr, string outStr, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
var lbDto = GetLibraryBook();
|
||||
lbDto.TitleWithSubtitle = @"s\l/a\s/h\e/s";
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
var directory = Path.GetDirectoryName(inStr)!;
|
||||
var fileName = Path.GetFileName(inStr);
|
||||
var lbDto = GetLibraryBook();
|
||||
lbDto.TitleWithSubtitle = @"s\l/a\s/h\e/s";
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue();
|
||||
var directory = Path.GetDirectoryName(inStr)!;
|
||||
var fileName = Path.GetFileName(inStr);
|
||||
|
||||
fileNamingTemplate.GetFilename(lbDto, directory, "txt", Replacements).PathWithoutPrefix.Should().Be(outStr);
|
||||
}
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(fileName, out var fileNamingTemplate).Should().BeTrue();
|
||||
|
||||
fileNamingTemplate.GetFilename(lbDto, directory, "txt", culture: null, replacements: Replacements).PathWithoutPrefix.Should().Be(outStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -653,8 +879,10 @@ namespace Templates_Folder_Tests
|
||||
[TestClass]
|
||||
public class GetErrors
|
||||
{
|
||||
private static readonly PlatformID[] Win32NtAndUnix = [PlatformID.Win32NT, PlatformID.Unix];
|
||||
|
||||
[TestMethod]
|
||||
public void null_is_invalid() => Tests(null, PlatformID.Win32NT | PlatformID.Unix, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
|
||||
public void null_is_invalid() => Tests(null, Win32NtAndUnix, new[] { NamingTemplate.ErrorNullIsInvalid });
|
||||
|
||||
[TestMethod]
|
||||
public void empty_is_valid() => valid_tests("");
|
||||
@@ -670,19 +898,19 @@ namespace Templates_Folder_Tests
|
||||
[DataRow(@"foo\bar")]
|
||||
[DataRow(@"<id>")]
|
||||
[DataRow(@"<id>\<title>")]
|
||||
public void valid_tests(string template) => Tests(template, PlatformID.Win32NT | PlatformID.Unix, Array.Empty<string>());
|
||||
public void valid_tests(string template) => Tests(template, Win32NtAndUnix, Array.Empty<string>());
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\", PlatformID.Win32NT, Templates.ERROR_FULL_PATH_IS_INVALID)]
|
||||
public void Tests(string? template, PlatformID platformID, params string[] expected)
|
||||
[DataRow([@"C:\", new[] { PlatformID.Win32NT }, Templates.ErrorFullPathIsInvalid])]
|
||||
public void Tests(string? template, PlatformID[] platformIds, params string[] expected)
|
||||
{
|
||||
if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
|
||||
var result = folderTemplate.Errors;
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
if (!platformIds.Contains(Environment.OSVersion.Platform))
|
||||
Assert.Inconclusive($"Skipped because OS is not one of {platformIds}.");
|
||||
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
|
||||
var result = folderTemplate.Errors.ToList();
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -693,27 +921,27 @@ namespace Templates_Folder_Tests
|
||||
public void null_is_invalid() => Templates.TryGetTemplate<Templates.FolderTemplate>(null, out _).Should().BeFalse();
|
||||
|
||||
[TestMethod]
|
||||
public void empty_is_valid() => Tests("", true, PlatformID.Win32NT | PlatformID.Unix);
|
||||
public void empty_is_valid() => Tests("", true, [PlatformID.Win32NT, PlatformID.Unix]);
|
||||
|
||||
[TestMethod]
|
||||
public void whitespace_is_valid() => Tests(" ", true, PlatformID.Win32NT | PlatformID.Unix);
|
||||
public void whitespace_is_valid() => Tests(" ", true, [PlatformID.Win32NT, PlatformID.Unix]);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\", false, PlatformID.Win32NT)]
|
||||
[DataRow(@"foo", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"\foo", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"\foo\", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"foo\bar", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"<id>", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
[DataRow(@"<id>\<title>", true, PlatformID.Win32NT | PlatformID.Unix)]
|
||||
public void Tests(string template, bool expected, PlatformID platformID)
|
||||
[DataRow(@"C:\", false, new[] { PlatformID.Win32NT })]
|
||||
[DataRow(@"foo", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"\foo", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"foo\", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"\foo\", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"foo\bar", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"<id>", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
[DataRow(@"<id>\<title>", true, new[] { PlatformID.Win32NT, PlatformID.Unix })]
|
||||
public void Tests(string template, bool expected, PlatformID[] platformIds)
|
||||
{
|
||||
if ((platformID & Environment.OSVersion.Platform) == Environment.OSVersion.Platform)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue();
|
||||
folderTemplate.IsValid.Should().Be(expected);
|
||||
}
|
||||
if (!platformIds.Contains(Environment.OSVersion.Platform))
|
||||
Assert.Inconclusive($"Skipped because OS is not one of {platformIds}.");
|
||||
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate).Should().BeTrue();
|
||||
folderTemplate.IsValid.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -721,25 +949,25 @@ namespace Templates_Folder_Tests
|
||||
public class GetWarnings
|
||||
{
|
||||
[TestMethod]
|
||||
public void null_is_invalid() => Tests(null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
|
||||
public void null_is_invalid() => Tests(null, new[] { NamingTemplate.ErrorNullIsInvalid });
|
||||
|
||||
[TestMethod]
|
||||
public void empty_has_warnings() => Tests("", NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS);
|
||||
public void empty_has_warnings() => Tests("", NamingTemplate.WarningEmpty, NamingTemplate.WarningNoTags);
|
||||
|
||||
[TestMethod]
|
||||
public void whitespace_has_warnings() => Tests(" ", NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS);
|
||||
public void whitespace_has_warnings() => Tests(" ", NamingTemplate.WarningWhiteSpace, NamingTemplate.WarningNoTags);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"<id>\foo\bar")]
|
||||
public void valid_tests(string template) => Tests(template, Array.Empty<string>());
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"no tags", NamingTemplate.WARNING_NO_TAGS)]
|
||||
[DataRow("<ch#> chapter tag", NamingTemplate.WARNING_NO_TAGS)]
|
||||
[DataRow(@"no tags", NamingTemplate.WarningNoTags)]
|
||||
[DataRow("<ch#> chapter tag", NamingTemplate.WarningNoTags)]
|
||||
public void Tests(string? template, params string[] expected)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FolderTemplate>(template, out var folderTemplate);
|
||||
var result = folderTemplate.Warnings;
|
||||
var result = folderTemplate.Warnings.ToList();
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
@@ -779,10 +1007,10 @@ namespace Templates_Folder_Tests
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
public void empty() => Tests("", 0);
|
||||
public void Empty() => Tests("", 0);
|
||||
|
||||
[TestMethod]
|
||||
public void whitespace() => Tests(" ", 0);
|
||||
public void Whitespace() => Tests(" ", 0);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("no tags", 0)]
|
||||
@@ -808,7 +1036,7 @@ namespace Templates_File_Tests
|
||||
public class GetErrors
|
||||
{
|
||||
[TestMethod]
|
||||
public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { NamingTemplate.ERROR_NULL_IS_INVALID });
|
||||
public void null_is_invalid() => Tests(null, Environment.OSVersion.Platform, new[] { NamingTemplate.ErrorNullIsInvalid });
|
||||
|
||||
[TestMethod]
|
||||
public void empty_is_valid() => valid_tests("");
|
||||
@@ -821,15 +1049,15 @@ namespace Templates_File_Tests
|
||||
[DataRow(@"<id>")]
|
||||
public void valid_tests(string template) => Tests(template, Environment.OSVersion.Platform, Array.Empty<string>());
|
||||
|
||||
public void Tests(string? template, PlatformID platformID, params string[] expected)
|
||||
private void Tests(string? template, PlatformID platformId, params string[] expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate);
|
||||
var result = fileTemplate.Errors;
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate);
|
||||
var result = fileTemplate.Errors.ToList();
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -887,13 +1115,13 @@ namespace Templates_ChapterFile_Tests
|
||||
public class GetWarnings
|
||||
{
|
||||
[TestMethod]
|
||||
public void null_is_invalid() => Tests(null, null, new[] { NamingTemplate.ERROR_NULL_IS_INVALID, Templates.WARNING_NO_CHAPTER_NUMBER_TAG });
|
||||
public void null_is_invalid() => Tests(null, null, new[] { NamingTemplate.ErrorNullIsInvalid, Templates.WarningNoChapterNumberTag });
|
||||
|
||||
[TestMethod]
|
||||
public void empty_has_warnings() => Tests("", null, NamingTemplate.WARNING_EMPTY, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
public void empty_has_warnings() => Tests("", null, NamingTemplate.WarningEmpty, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag);
|
||||
|
||||
[TestMethod]
|
||||
public void whitespace_has_warnings() => Tests(" ", null, NamingTemplate.WARNING_WHITE_SPACE, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG);
|
||||
public void whitespace_has_warnings() => Tests(" ", null, NamingTemplate.WarningWhiteSpace, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<ch#>")]
|
||||
@@ -901,22 +1129,19 @@ namespace Templates_ChapterFile_Tests
|
||||
public void valid_tests(string template) => Tests(template, null, Array.Empty<string>());
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"no tags", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
|
||||
[DataRow(@"<id>\foo\bar", true, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
|
||||
[DataRow(@"<id>/foo/bar", false, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
|
||||
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WARNING_NO_TAGS, Templates.WARNING_NO_CHAPTER_NUMBER_TAG)]
|
||||
public void Tests(string? template, bool? windows, params string[] expected)
|
||||
[DataRow(@"no tags", null, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag)]
|
||||
[DataRow(@"<id>\foo\bar", PlatformID.Win32NT, Templates.WarningNoChapterNumberTag)]
|
||||
[DataRow(@"<id>/foo/bar", PlatformID.Unix, Templates.WarningNoChapterNumberTag)]
|
||||
[DataRow("<chapter count> -- chapter tag but not ch# or ch_#", null, NamingTemplate.WarningNoTags, Templates.WarningNoChapterNumberTag)]
|
||||
public void Tests(string? template, PlatformID? platformId, params string[] expected)
|
||||
{
|
||||
if (windows is null
|
||||
|| (windows is true && Environment.OSVersion.Platform is PlatformID.Win32NT)
|
||||
|| (windows is false && Environment.OSVersion.Platform is PlatformID.Unix))
|
||||
{
|
||||
if (platformId is not null && Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate);
|
||||
var result = chapterFileTemplate.Warnings;
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterFileTemplate);
|
||||
var result = chapterFileTemplate.Warnings.ToList();
|
||||
result.Should().HaveCount(expected.Length);
|
||||
result.Should().BeEquivalentTo(expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -984,16 +1209,16 @@ namespace Templates_ChapterFile_Tests
|
||||
[DataRow("[<id>] <ch# 0> of <ch count> - <ch title>", @"/foo/", "txt", 6, 10, "chap", @"/foo/[asin] 06 of 10 - chap.txt", PlatformID.Unix)]
|
||||
[DataRow("<ch#>", @"C:\foo\", "txt", 6, 10, "chap", @"C:\foo\6.txt", PlatformID.Win32NT)]
|
||||
[DataRow("<ch#>", @"/foo/", "txt", 6, 10, "chap", @"/foo/6.txt", PlatformID.Unix)]
|
||||
public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformID)
|
||||
public void Tests(string template, string dir, string ext, int pos, int total, string chapter, string expected, PlatformID platformId)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterTemplate).Should().BeTrue();
|
||||
chapterTemplate
|
||||
.GetFilename(GetLibraryBook(), new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, ext, Default)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
if (Environment.OSVersion.Platform != platformId)
|
||||
Assert.Inconclusive($"Skipped because OS is not {platformId}.");
|
||||
|
||||
Templates.TryGetTemplate<Templates.ChapterFileTemplate>(template, out var chapterTemplate).Should().BeTrue();
|
||||
chapterTemplate
|
||||
.GetFilename(GetLibraryBook(), new() { OutputFileName = $"xyz.{ext}", PartsPosition = pos, PartsTotal = total, Title = chapter }, dir, ext, culture: null, replacements: Default)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Globalization;
|
||||
using AssertionHelper;
|
||||
using LibationSearchEngine;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
@@ -72,6 +73,8 @@ public class FormatSearchQuery
|
||||
|
||||
public void FormattingTest(string input, string output)
|
||||
{
|
||||
CultureInfo.DefaultThreadCurrentCulture = CultureInfo.InvariantCulture;
|
||||
|
||||
using var analyzer = new StandardAnalyzer(SearchEngine.Version);
|
||||
|
||||
QuerySanitizer.Sanitize(input, analyzer).Should().Be(output);
|
||||
|
||||
@@ -26,6 +26,7 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
| \<series\> | All series to which the book belongs (if any) | [Series List](#series-list-formatters) |
|
||||
| \<first series\> | First series | [Series](#series-formatters) |
|
||||
| \<series#\> | Number order in series (alias for \<first series[{#}]\> | [Number](#number-formatters) |
|
||||
| \<minutes\> | Duration of the audiobook in minutes | [TimeSpan](#timespan-formatters) |
|
||||
| \<bitrate\> | Bitrate (kbps) of the last downloaded audiobook | [Number](#number-formatters) |
|
||||
| \<samplerate\> | Sample rate (Hz) of the last downloaded audiobook | [Number](#number-formatters) |
|
||||
| \<channels\> | Number of audio channels in the last downloaded audiobook | [Number](#number-formatters) |
|
||||
@@ -34,6 +35,8 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
| \<libation version\> | Libation version used during last download of the audiobook | [Text](#text-formatters) |
|
||||
| \<account\> | Audible account of this book | [Text](#text-formatters) |
|
||||
| \<account nickname\> | Audible account nickname of this book | [Text](#text-formatters) |
|
||||
| \<tag\> | Tag(s) | [Text List](#text-list-formatters) |
|
||||
| \<first tag\> | First tag | [Text](#text-formatters) |
|
||||
| \<locale\> | Region/country | [Text](#text-formatters) |
|
||||
| \<year\> | Year published | [Number](#number-formatters) |
|
||||
| \<language\> | Book's language | [Text](#text-formatters) |
|
||||
@@ -56,13 +59,17 @@ To change how these properties are displayed, [read about custom formatters](#ta
|
||||
|
||||
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|
||||
|
||||
| Tag | Description | Type |
|
||||
| -------------------------------------------------- | ----------------------------------------------------------------- | ----------- |
|
||||
| \<if series-\>...\<-if series\> | Only include if part of a book series or podcast | Conditional |
|
||||
| \<if podcast-\>...\<-if podcast\> | Only include if part of a podcast | Conditional |
|
||||
| \<if bookseries-\>...\<-if bookseries\> | Only include if part of a book series | Conditional |
|
||||
| \<if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional |
|
||||
| \<has PROPERTY-\>...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional |
|
||||
| Tag | Description | Type |
|
||||
| ------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------- | ----------- |
|
||||
| \<if series-\>...\<-if series\> | Only include if part of a book series or podcast | Conditional |
|
||||
| \<if podcast-\>...\<-if podcast\> | Only include if part of a podcast | Conditional |
|
||||
| \<if bookseries-\>...\<-if bookseries\> | Only include if part of a book series | Conditional |
|
||||
| \<if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is a podcast series parent | Conditional |
|
||||
| \<if abridged-\>...\<-if abridged\> | Only include if item is abridged | Conditional |
|
||||
| \<has PROPERTY-\>...\<-has\> | Only include if the PROPERTY has a value (i.e. not null or empty) | Conditional |
|
||||
| \<is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if the PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional |
|
||||
| \<is PROPERTY[FORMAT][[CHECK](#checks)]-\>...\<-is\> | Only include if the formatted PROPERTY or a single value of a list PROPERTY satisfies the CHECK | Conditional |
|
||||
| \<is PROPERTY[...separator(...)...][[CHECK](#checks)]-\>...\<-is\> | Only include if the joined form of all formatted values of a list PROPERTY satisfies the CHECK | Conditional |
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
@@ -70,21 +77,22 @@ For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's
|
||||
|
||||
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|
||||
|
||||
| Inverted Tag | Description | Type |
|
||||
| --------------------------------------------------- | ---------------------------------------------------------------------------- | ----------- |
|
||||
| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional |
|
||||
| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional |
|
||||
| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional |
|
||||
| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional |
|
||||
| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional |
|
||||
| Inverted Tag | Description | Type |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------------------ | ----------- |
|
||||
| \<!if series-\>...\<-if series\> | Only include if _not_ part of a book series or podcast | Conditional |
|
||||
| \<!if podcast-\>...\<-if podcast\> | Only include if _not_ part of a podcast | Conditional |
|
||||
| \<!if bookseries-\>...\<-if bookseries\> | Only include if _not_ part of a book series | Conditional |
|
||||
| \<!if podcastparent-\>...\<-if podcastparent\>**†** | Only include if item is _not_ a podcast series parent | Conditional |
|
||||
| \<!has PROPERTY-\>...\<-has\> | Only include if the PROPERTY _does not_ have a value (i.e. is null or empty) | Conditional |
|
||||
| \<!is PROPERTY[[CHECK](#checks)]-\>...\<-is\> | Only include if neither the whole PROPERTY nor the values of a list PROPERTY satisfies the CHECK | Conditional |
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
|
||||
|
||||
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
|
||||
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast><title>`
|
||||
|
||||
This example will add a number if the `<series#\>` tag has a value:
|
||||
This example will add a number if the `<series#>` tag has a value:
|
||||
|
||||
`<has series#><series#><-has>`
|
||||
|
||||
@@ -102,43 +110,88 @@ And this example will customize the title based on whether the book has a subtit
|
||||
|
||||
### Text Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| --------- | -------------------------- | ------------------ | ------------------------------------------- |
|
||||
| L | Converts text to lowercase | \<title[L]\> | a study in scarlet꞉ a sherlock holmes novel |
|
||||
| U | Converts text to uppercase | \<title short[U]\> | A STUDY IN SCARLET |
|
||||
Text formatting can change length and case of the text. Use \<#\>, \<#\>\<case\> or \<case\> to specify one or both of these.
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| --------- | --------------------------------------------------------------- | ------------------ | ------------------------------------------- |
|
||||
| # | Cuts down the text to the specified number of characters | \<title[14]\> | A Study in Scar |
|
||||
| L | Converts text to lowercase | \<title[L]\> | a study in scarlet꞉ a sherlock holmes novel |
|
||||
| U | Converts text to uppercase | \<title short[U]\> | A STUDY IN SCARLET |
|
||||
| t | Converts text to title case | \<title[t]\> | The Abc Murders |
|
||||
| T | Converts text to title case where uppercase words are preserved | \<title[T]\> | The ABC Murders |
|
||||
| | | \<title[6T]\> | The AB |
|
||||
|
||||
### Text List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |--------------------------------------------- | ---------------------------------------------|
|
||||
| separator() | Specify the text used to join<br>multiple entries.<br><br>Default is ", " | `<tag[separator(_)]>` | Tag1_Tag2_Tag3_Tag4_Tag5 |
|
||||
| format(\{S\}) | Formats the entries by placing their values into the specified template.<br>Use {S:[Text_Formatter](#text-formatters)} to place the entry and optionally apply a format. | `<tag[format(Tag={S:l})`<br>`separator(;)]>` | Tag=tag1;Tag=tag2;Tag=tag3;Tag=tag4;Tag=tag5 |
|
||||
| sort(S) | Sorts the elements by their value.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<tag[sort(s)`<br>`separator(;)]>` | Tag5;Tag4;Tag3;Tag2;Tag1 |
|
||||
| max(#) | Only use the first # of entries | `<tag[max(1)]>` | Tag1 |
|
||||
| slice(#) | Only use the nth entry of the list | `<tag[slice(2)]>` | Tag2 |
|
||||
| slice(#..) | Only use entries of the list starting from # | `<tag[slice(2..)]>` | Tag2, Tag3, Tag4, Tag5 |
|
||||
| slice(..#) | Like max(#). Only use the first # of entries | `<tag[slice(..1)]>` | Tag1 |
|
||||
| slice(#..#) | Only use entries of the list starting from # and ending at # (inclusive) | `<tag[slice(2..4)]>` | Tag2, Tag3, Tag4 |
|
||||
| slice(-#..-#) | Numbers may be specified negative. In that case positions ar counted from the end with -1 pointing at the last member | `<tag[slice(-3..-2)]>` | Tag3, Tag4 |
|
||||
|
||||
### Series Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
|
||||
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\} | `<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>` | Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0 |
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| \{N \| # \| ID\} | Formats the series using<br>the series part tags.<br>\{N:[Text_Formatter](#text-formatters)\} = Series Name<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series<br>\{ID:[Text_Formatter](#text-formatters)\} = Audible Series ID<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the corresponding formatter.<br><br>Default is \{N\} | `<first series>`\<hr\>`<first series[{N:l}]>`\<hr\>`<first series[{N}, {#}, {ID}]>`\<hr\>`<first series[{N:10U}, {ID}, {#:00.0}]>` | Sherlock Holmes\<hr\>sherlock holmes\<hr\>Sherlock Holmes, 1-6, B08376S3R2\<hr\>SHERLOCK H, B08376S3R2, 01.0-06.0 |
|
||||
|
||||
### Series List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ------------------------ | ----------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| separator() | Speficy the text used to join<br>multiple series names.<br><br>Default is ", " | `<series[separator(; )]>` | Sherlock Holmes; Some Other Series |
|
||||
| format(\{N \| # \| ID\}) | Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above. | `<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<series[format({ID}-{N}, {#:00.0})]>` | Sherlock Holmes, 1-6; Book Collection, 1<hr>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0 |
|
||||
| max(#) | Only use the first # of series<br><br>Default is all series | `<series[max(1)]>` | Sherlock Holmes |
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
|
||||
| separator() | Specify the text used to join<br>multiple series names.<br><br>Default is ", " | `<series[separator(; )]>` | Sherlock Holmes; Some Other Series |
|
||||
| format(\{N \| # \| ID\}) | Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above. | `<series[format({N}, {#})`<br>`separator(; )]>`\<hr\>`<series[format({ID}-{N}, {#:00.0})]>` | Sherlock Holmes, 1-6; Book Collection, 1\<hr\>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0 |
|
||||
| sort(N \| # \| ID) | Sorts the series by name, number or ID.<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multi‑level sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<series[sort(N)`<br>`separator(; )]>` | Book Collection, 1; Sherlock Holmes, 1-6 |
|
||||
| max(#) | Only use the first # of series | `<series[max(1)]>` | Sherlock Holmes |
|
||||
| slice(#..#) | Only use entries of the series list starting from # and ending at # (inclusive)<br><br>See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `<series[slice(..-2)]>` | Sherlock Holmes |
|
||||
|
||||
### Name Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- | ---------------------------------------------------------------------------- |
|
||||
| \{T \| F \| M \| L \| S \| ID\} | Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} | `<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>` | Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_ |
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------- | --------------------------------------- |
|
||||
| \{T \| F \| M \| L \| S \| ID\} | Formats the human name using<br>the name part tags.<br>\{T:[Text_Formatter](#text-formatters)\} = Title (e.g. "Dr.")<br>\{F:[Text_Formatter](#Text-Formatters)\} = First name<br>\{M:[Text_Formatter](#text-formatters)\} = Middle name<br>\{L:[Text_Formatter](#text-formatters)\} = Last Name<br>\{S:[Text_Formatter](#text-formatters)\} = Suffix (e.g. "PhD")<br>\{ID:[Text_Formatter](#text-formatters)\} = Audible Contributor ID<br><br>Formatter parts are optional and introduced by the colon. If specified the string will be used to format the part using the correspoing formatter.<br><br>Default is \{T\} \{F\} \{M\} \{L\} \{S\} | `<first narrator[{L}, {F:1}.]>`\<hr\>`<first author[{L:u}, {F} _{ID}_]>` | Fry, S.\<hr\>DOYLE, Arthur \_B000AQ43GQ\_ |
|
||||
|
||||
### Name List Formatters
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| --------------------------------------- | ---------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
|
||||
| separator() | Speficy the text used to join<br>multiple people's names.<br><br>Default is ", " | `<author[separator(; )]>` | Arthur Conan Doyle; Stephen Fry |
|
||||
| format(\{T \| F \| M \| L \| S \| ID\}) | Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above. | `<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>` | Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_ |
|
||||
| sort(F \| M \| L) | Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted | `<author[sort(M)]>` | Stephen Fry, Arthur Conan Doyle |
|
||||
| max(#) | Only use the first # of names<br><br>Default is all names | `<author[max(1)]>` | Arthur Conan Doyle |
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
| --------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| separator() | Specify the text used to join<br>multiple people's names.<br><br>Default is ", " | `<author[separator(; )]>` | Arthur Conan Doyle; Stephen Fry |
|
||||
| format(\{T \| F \| M \| L \| S \| ID\}) | Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above. | `<author[format({L:u}, {F})`<br>`separator(; )]>`\<hr\>`<author[format({L}, {F:1}.`<br>`_{ID}_) separator(; )]>` | DOYLE, Arthur; FRY, Stephen\<hr\>Doyle, A. \_B000AQ43GQ\_;<br>Fry, S. \_B000APAGVS\_ |
|
||||
| sort(T \| F \| M \| L \| S \| ID) | Sorts the names by title,<br> first, middle, or last name,<br>suffix or Audible Contributor ID<br><br>These terms define the primary, secondary, tertiary, … sorting order.<br>You may combine multiple terms in sequence to specify multi‑level sorting.<br><br>*Sorting direction:*<br>uppercase = ascending<br>lowercase = descending<br><br>Default is unsorted | `<author[sort(M)]>`\<hr\>`<author[sort(Fl)]>`\<hr\>`<author[sort(L FM ID)]>` | Stephen Fry, Arthur Conan Doyle\<hr\>Stephen King, Stephen Fry\<hr\>John P. Smith \_B000TTTBBB\_, John P. Smith \_B000TTTCCC\_, John S. Smith \_B000HHHVVV\_ |
|
||||
| max(#) | Only use the first # of names<br><br>Default is all names | `<author[max(1)]>` | Arthur Conan Doyle |
|
||||
| slice(#..#) | Only use entries of the names list starting from # and ending at # (inclusive)<br><br>See [Text List Formatter Usage](#Text-List-Formatters) above for details on all the variants of `slice()` | `<author[slice(..-2)]>` | Arthur Conan Doyle |
|
||||
|
||||
### TimeSpan Formatters
|
||||
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-timespan-format-strings).
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|-----------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|---------------------------|----------------|
|
||||
| d | The "d" custom format specifier outputs the value of the TimeSpan.Days property, which represents the number of whole days in the time interval. It outputs the full number of days in a TimeSpan value, even if the value has more than one digit. If the value of the TimeSpan.Days property is zero, the specifier outputs "0".<br><br>Use "dd"-"dddddddd" for zero padding up to the specified size. | \<minutes[dd]\> | 02 |
|
||||
| h | The "h" custom format specifier outputs the value of the TimeSpan.Hours property, which represents the number of whole hours in the time interval that isn't counted as part of its day component. It returns a one-digit string value if the value of the TimeSpan.Hours property is 0 through 9, and it returns a two-digit string value if the value of the TimeSpan.Hours property ranges from 10 to 23.<br><br>Use "hh" for zero padding. | \<minutes[hh]\> | 14 |
|
||||
| m | The "m" custom format specifier outputs the value of the TimeSpan.Minutes property, which represents the number of whole minutes in the time interval that isn't counted as part of its day component. It returns a one-digit string value if the value of the TimeSpan.Minutes property is 0 through 9, and it returns a two-digit string value if the value of the TimeSpan.Minutes property ranges from 10 to 59.<br><br>Use "mm" for zero padding. | \<minutes[m]\> | 42 |
|
||||
| 'string' | Literal string delimiter. | \<minutes[d'd 'h'h 'm'm']\> | 2d 14h 42m |
|
||||
| \\ | The escape character. | \<minutes[d\\d h\\h m\\m]\> | 2d 14h 42m |
|
||||
|
||||
These formatters have been enhanced to allow the display of days, hours or months beyond their usual limits. For example, the total number of hours, even if it exceeds 23.
|
||||
Here, a number format is inserted for the desired part in accordance with [Microsoft’s instructions](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings). Unlike standard number formats, however, the letters D, H or M (uppercase) are used instead of zeros.
|
||||
|
||||
| Formatter | Description | Example Usage | Example Result |
|
||||
|-----------|-----------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------|-------------------|
|
||||
| D | A number format with "D" instead of "0". Using this will output the total number of days and reduce the amount of minutes avalable for "H" and "M". | \<minutes[DD]\> | 02 |
|
||||
| H | A number format with "H" instead of "0". Using this will output the total number of hours and reduce the amount of minutes available for "M". | \<minutes[HH]\> | 62 |
|
||||
| M | A number format with "H" instead of "0". Using this will output the total number of minutes. | \<minutes[#,#MM]\> | 3,762 |
|
||||
| D H M | A combination of the above. | \<minutes[D'days 'MM'minutes']\> | 02days 882minutes |
|
||||
|
||||
### Number Formatters
|
||||
|
||||
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|
||||
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||
@@ -147,7 +200,7 @@ For more custom formatters and examples, [see this guide from Microsoft](https:/
|
||||
|
||||
### Date Formatters
|
||||
|
||||
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||
For more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||
|
||||
#### Standard DateTime Formatters
|
||||
|
||||
@@ -166,3 +219,31 @@ You can use custom formatters to construct customized DateTime string. For more
|
||||
|MM|2-digit month|\<file date[MM]\>|02|
|
||||
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|
||||
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
|
||||
|
||||
### Checks
|
||||
|
||||
| Check-Pattern | Description | Example |
|
||||
| --------------- | ------------------------------------------------------------------------------- | --------------------------------------- |
|
||||
| =STRING **†** | Matches if one item is equal to STRING (case ignored) | \<is tag[=Tag1]-\> |
|
||||
| !=STRING **†** | Matches if one item is not equal to STRING (case ignored) | \<is first author[!=Arthur]-\> |
|
||||
| ~STRING **†** | Matches if one items is matched by the regular expression STRING (case ignored) | \<is title[~(\[XYZ\]).*\\1]-\> |
|
||||
| #=NUMBER **‡** | Matches if the number value is equal to NUMBER | \<is channels[#=2]-\> |
|
||||
| #!=NUMBER **‡** | Matches if the number value is not equal to NUMBER | \<is author[#!=1]-\> |
|
||||
| #\>=NUMBER **‡** | Matches if the number value is greater than or equal to NUMBER | \<is bitrate[#\>=128]-\> |
|
||||
| #\>NUMBER **‡** | Matches if the number value is greater than NUMBER | \<is title[#\>30]-\> |
|
||||
| #\<=NUMBER **‡** | Matches if the number value is less than or equal to NUMBER | \<is first narrator[format({F})][#\<=1]-\> |
|
||||
| #\<NUMBER **‡** | Matches if the number value is less than NUMBER | \<is author[#\<3]-\> |
|
||||
|
||||
**†** STRING maybe escaped with a backslash. So even square brackets could be used. If a single backslash should be part of the string, it must be doubled.
|
||||
|
||||
**‡** NUMBER checks on lists are checking the size of the list. If the value to check is a string, its length is used.
|
||||
|
||||
#### More complex examples
|
||||
|
||||
This example will truncate the title to 4 characters and check its (trimmed) value to be "the" in any case:
|
||||
|
||||
`<is title[4][=the]>`
|
||||
|
||||
Here the second to fourth tag is taken and joined with a colon. The result is then checked to be equal to "Tag2:Tag3:Tag4":
|
||||
|
||||
`<is tag[separator(:)slice(2..4)][=Tag2:Tag3:Tag4]->`
|
||||
Reference in new issue
Block a user