A Windows tray-resident photo importer for ecommerce sellers.
This commit is contained in:
commit
c0ea492ec5
|
|
@ -0,0 +1,48 @@
|
|||
# AutoIngest .gitignore
|
||||
# Build outputs and tooling caches are ignored. The two publish/ folders below
|
||||
# are intentionally NOT ignored — they hold the distributable .exe builds that
|
||||
# get committed for end users.
|
||||
|
||||
## .NET build output
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
## Ignore Csharp publish output directories
|
||||
**/publish/
|
||||
**/publish-standalone/
|
||||
|
||||
## Visual Studio / IDE state
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.vspx
|
||||
|
||||
## Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
## ReSharper / Rider
|
||||
.idea/
|
||||
*.DotSettings.user
|
||||
|
||||
## Publish working dirs (these are created locally by the publish scripts when
|
||||
## testing intermediate output; the canonical builds live in publish/ and
|
||||
## publish-standalone/ at the repo root, which are tracked).
|
||||
publish-temp/
|
||||
publish-test/
|
||||
|
||||
## OS-generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
.DS_Store
|
||||
|
||||
## NuGet (project-level; the single PackageReference is restored from nuget.org)
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
|
||||
## Logs and local config scraps
|
||||
*.log
|
||||
*.tmp
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
# Visual Studio Version 17
|
||||
VisualStudioVersion = 17.0.31903.59
|
||||
MinimumVisualStudioVersion = 10.0.40219.1
|
||||
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "AutoIngest", "AutoIngest\AutoIngest.csproj", "{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
Release|Any CPU = Release|Any CPU
|
||||
EndGlobalSection
|
||||
GlobalSection(ProjectConfigurationPlatforms) = postSolution
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{A1B2C3D4-E5F6-7890-ABCD-EF1234567890}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
# AutoIngest .gitignore
|
||||
# Build outputs and tooling caches are ignored. The two publish/ folders below
|
||||
# are intentionally NOT ignored — they hold the distributable .exe builds that
|
||||
# get committed for end users.
|
||||
|
||||
## .NET build output
|
||||
**/bin/
|
||||
**/obj/
|
||||
|
||||
## Visual Studio / IDE state
|
||||
.vs/
|
||||
*.user
|
||||
*.suo
|
||||
*.userprefs
|
||||
*.vspx
|
||||
|
||||
## Visual Studio Code
|
||||
.vscode/
|
||||
|
||||
## ReSharper / Rider
|
||||
.idea/
|
||||
*.DotSettings.user
|
||||
|
||||
## Publish working dirs (these are created locally by the publish scripts when
|
||||
## testing intermediate output; the canonical builds live in publish/ and
|
||||
## publish-standalone/ at the repo root, which are tracked).
|
||||
publish-temp/
|
||||
publish-test/
|
||||
|
||||
## OS-generated files
|
||||
Thumbs.db
|
||||
ehthumbs.db
|
||||
Desktop.ini
|
||||
$RECYCLE.BIN/
|
||||
.DS_Store
|
||||
|
||||
## NuGet (project-level; the single PackageReference is restored from nuget.org)
|
||||
*.nupkg
|
||||
*.snupkg
|
||||
.nuget/
|
||||
|
||||
## Logs and local config scraps
|
||||
*.log
|
||||
*.tmp
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
## Tray icon = micro brand mark with auto-contrast background
|
||||
|
||||
### Confirmed design (from your answers)
|
||||
- **Mark content:** logo if the user has one (scaled to fit), else the first letter of their store name, else the current green circle (branding off / nothing to show).
|
||||
- **Visual treatment:** rounded-square tile with an auto-chosen contrasting background — light mark → dark bg, dark mark → light bg. Win11-taskbar-badge aesthetic.
|
||||
|
||||
### The color-matching algorithm (the "intelligent" part)
|
||||
1. Render the mark onto a 64×64 transparent canvas (super-sampled for crispness, then downsampled to 16×16).
|
||||
2. Sample the mark's luminance:
|
||||
- Logo: median luminance of the non-transparent pixels (median, not average, so a black-and-white logo doesn't average to gray and confuse the contrast decision).
|
||||
- Initial: the luminance of the color we drew it with (we control this, so it's exact).
|
||||
3. Pick background by contrast: luminance ≥ 0.5 → background = near-black `#1f1f1f`; luminance < 0.5 → background = near-white `#f0f0f0`. Strong backgrounds guarantee legibility on either taskbar color.
|
||||
4. Round the corners into a tile, composite the mark centered, downsample to 16×16 with high-quality interpolation.
|
||||
|
||||
### Implementation
|
||||
|
||||
**1. `App/TrayIconFactory.cs` (new)** — builds the tray `Bitmap` from branding:
|
||||
- `Bitmap Build(BrandingConfig? branding, AppConfig config)`:
|
||||
- If branding off / nothing configured → return the existing lime-green circle (current behavior, no regression).
|
||||
- If `LogoWatermarkEnabled` + `LogoPath` exists → render the logo via Magick.NET onto a 64px transparent canvas, fit-contained with ~10% padding.
|
||||
- Else if `TextWatermarkEnabled` or `StoreName` set → draw the store-name initial (first non-space char of `StoreName`, uppercased) in bold Segoe UI, sized to fill ~70% of the canvas. Color: white if no logo (we'll let the contrast step pick the bg); actually for an initial we draw it in a *neutral* dark color so the algo can pair it with a light bg, then the mark color is known.
|
||||
- Compute mark luminance from the rendered pixels.
|
||||
- Pick background per the algorithm above.
|
||||
- Composite: rounded-rect background → mark centered → downsample 64→16 with `HighQualityBicubic`.
|
||||
- Return the 16×16 bitmap.
|
||||
- All paths wrapped: a corrupt logo or font failure falls back to the green circle, never crashes the tray.
|
||||
|
||||
**2. `MainForm` integration:**
|
||||
- Replace the hard-coded green-circle bitmap block in `BuildTrayIcon()` with `TrayIconFactory.Build(_config.Branding, _config)`.
|
||||
- Hold the bitmap in `_trayBitmap` as today (NotifyIcon handle lifetime).
|
||||
- Add `void RefreshTrayIcon()` — rebuilds the bitmap, swaps `trayIcon.Icon`, disposes the old bitmap/hicon. Safe to call repeatedly.
|
||||
- Call `RefreshTrayIcon()` after the welcome wizard finishes (first-launch branding setup) and after `BrandingForm` saves (Options ▸ Branding ▸ Edit branding…). So the icon updates live when the user changes branding.
|
||||
|
||||
**3. Magick.NET vs System.Drawing for the render:**
|
||||
- The *logo scaling* uses Magick.NET (already a dependency; handles PNG alpha, resize cleanly).
|
||||
- The *tile compositing + initial text + downsampling* uses `System.Drawing` (GDI+) — it's already in use for the current tray icon and is simpler for rounded-rect + text + hicon. No new dependency either way.
|
||||
|
||||
### Files
|
||||
- **NEW (1):** `App/TrayIconFactory.cs`.
|
||||
- **EDIT (1):** `App/MainForm.cs` (replace bitmap block, add `RefreshTrayIcon`, call it after wizard + branding save).
|
||||
- **Docs:** README mention; BLOG short note on the color-matching algorithm.
|
||||
|
||||
### Behavior contract & risks
|
||||
- **No regression** when branding is off — exact current green circle.
|
||||
- **Corrupt/missing logo** → falls back to initial → falls back to green, never an empty tray.
|
||||
- **Live update** when branding changes (wizard or editor).
|
||||
- **Reads on both taskbar themes** because the tile carries its own guaranteed-contrast background.
|
||||
- **No new dependency.**
|
||||
|
||||
### Verification
|
||||
`dotnet build` clean Debug + Release. Manual: branding off → green circle; set store name "ReelDeals", enable text watermark → "R" tile with auto-contrast bg; add a logo → logo tile; toggle branding off → green circle restored.
|
||||
|
|
@ -0,0 +1,195 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Self-install + autostart, the classic Windows way: copy the running exe to a stable
|
||||
/// per-user home and drop a <c>.lnk</c> in the user's Startup folder so Explorer launches it
|
||||
/// at login. No admin rights, no UAC, no registry — both
|
||||
/// <c>%LocalAppData%\Programs\AutoIngest\</c> and <c>shell:startup</c> are user-writable.
|
||||
///
|
||||
/// The modern-correct target for the exe is <c>%LocalAppData%\Programs\</c> (this is where
|
||||
/// VS Code, Discord, and other per-user installers live). The old-school
|
||||
/// <c>%SystemRoot%\</c> (<c>C:\Windows\</c>) target is deliberately NOT used: it's admin-
|
||||
/// owned, write-protected, semantically "part of the OS," more heavily scrutinized by
|
||||
/// Defender, and can be wiped by a Windows reset. LocalAppData achieves everything the
|
||||
/// Win9x system-root copy did, without those costs.
|
||||
///
|
||||
/// The shortcut itself is the persisted "enabled" state — <see cref="IsEnabled"/> is just
|
||||
/// File.Exists on the Startup link — so there's no config flag to drift out of sync with
|
||||
/// reality (e.g. if the user manually deletes the shortcut, the toggle reflects that).
|
||||
/// </summary>
|
||||
public static class AutostartManager
|
||||
{
|
||||
/// <summary>Stable per-user home for the installed exe.</summary>
|
||||
public static string StableHome =>
|
||||
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"Programs", "AutoIngest");
|
||||
|
||||
/// <summary>Full path of the installed exe in the stable home.</summary>
|
||||
public static string StableExe => Path.Combine(StableHome, "AutoIngest.exe");
|
||||
|
||||
/// <summary>The <c>.lnk</c> in the user's Startup folder that triggers login launch.</summary>
|
||||
public static string StartupLink
|
||||
{
|
||||
get
|
||||
{
|
||||
string startupFolder = Environment.GetFolderPath(Environment.SpecialFolder.Startup);
|
||||
return Path.Combine(startupFolder, "AutoIngest.lnk");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>True when autostart is active (the Startup shortcut exists).</summary>
|
||||
public static bool IsEnabled => File.Exists(StartupLink);
|
||||
|
||||
/// <summary>True when the currently-running process IS the stable-home copy.</summary>
|
||||
public static bool IsRunningFromStableHome =>
|
||||
string.Equals(Environment.ProcessPath, StableExe,
|
||||
StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
/// <summary>
|
||||
/// Installs the exe to <see cref="StableHome"/> and creates the Startup shortcut. Safe to
|
||||
/// call repeatedly — skips the copy if already running from the stable home, and refreshes
|
||||
/// the copy if the running exe is newer/different. Returns true if the exe was (re)copied
|
||||
/// this call, false if the stable copy was already current. Throws on I/O failure so the UI
|
||||
/// can surface the error and revert the toggle.
|
||||
/// </summary>
|
||||
public static bool Enable()
|
||||
{
|
||||
Directory.CreateDirectory(StableHome);
|
||||
|
||||
string? runningPath = Environment.ProcessPath;
|
||||
bool copied = false;
|
||||
|
||||
if (!string.IsNullOrEmpty(runningPath) &&
|
||||
!IsRunningFromStableHome)
|
||||
{
|
||||
// Copy the running exe to the stable home, refreshing if it's stale. Copying the
|
||||
// running image *to a different path* is fine on Windows (the exe is opened with
|
||||
// read/share); we never copy *over* the running path.
|
||||
if (NeedsRefresh(runningPath, StableExe))
|
||||
{
|
||||
File.Copy(runningPath, StableExe, overwrite: true);
|
||||
copied = true;
|
||||
}
|
||||
}
|
||||
|
||||
// (Re)create the Startup shortcut pointing at the stable exe.
|
||||
CreateShortcut(StartupLink, StableExe, StableHome, "AutoIngest photo importer");
|
||||
return copied;
|
||||
}
|
||||
|
||||
/// <summary>Removes the Startup shortcut. Leaves the stable-home exe in place so the user
|
||||
/// can re-enable without a re-copy. No-op if already disabled.</summary>
|
||||
public static void Disable()
|
||||
{
|
||||
if (File.Exists(StartupLink))
|
||||
File.Delete(StartupLink);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True if the destination doesn't exist or differs from the source by size or write time.
|
||||
/// Used to decide whether to re-copy the exe on re-enable (e.g. after the user updates).
|
||||
/// </summary>
|
||||
static bool NeedsRefresh(string source, string dest)
|
||||
{
|
||||
if (!File.Exists(dest)) return true;
|
||||
try
|
||||
{
|
||||
var s = new FileInfo(source);
|
||||
var d = new FileInfo(dest);
|
||||
return s.Length != d.Length
|
||||
|| s.LastWriteTimeUtc > d.LastWriteTimeUtc.AddSeconds(1);
|
||||
}
|
||||
catch { return true; }
|
||||
}
|
||||
|
||||
// ---- .lnk creation via the raw IShellLinkW COM interface (no IWshRuntimeLibrary) ----
|
||||
// This is the old-school way: CoCreateInstance the Shell.Link object, set path/working dir
|
||||
// through IShellLinkW, then persist via IPersistFile. Self-contained, no extra reference.
|
||||
|
||||
static void CreateShortcut(string linkPath, string targetPath, string workingDir, string description)
|
||||
{
|
||||
// CLSID_ShellLink — the Shell Link object. CoCreateInstance gives us an object that
|
||||
// implements both IShellLinkW (for setting target/working dir) and IPersistFile (for
|
||||
// saving the .lnk). We QI for IUnknown and cast to each interface.
|
||||
var clsid = new Guid("00021401-0000-0000-C000-000000000046");
|
||||
var iidUnknown = new Guid("00000000-0000-0000-C000-000000000046"); // IID_IUnknown
|
||||
|
||||
int hr = CoCreateInstance(ref clsid, IntPtr.Zero,
|
||||
1 /*CLSCTX_INPROC_SERVER*/, ref iidUnknown, out object obj);
|
||||
if (hr != 0)
|
||||
Marshal.ThrowExceptionForHR(hr);
|
||||
|
||||
try
|
||||
{
|
||||
var shellLink = (IShellLinkW)obj;
|
||||
shellLink.SetPath(targetPath);
|
||||
shellLink.SetWorkingDirectory(workingDir);
|
||||
shellLink.SetDescription(description);
|
||||
|
||||
// Save via IPersistFile. remember=1 lets the shell track the link (so it can
|
||||
// heal the target path if the exe moves later).
|
||||
var persistFile = (IPersistFile)obj;
|
||||
persistFile.Save(linkPath, remember: 1);
|
||||
}
|
||||
finally
|
||||
{
|
||||
Marshal.ReleaseComObject(obj);
|
||||
}
|
||||
}
|
||||
|
||||
[DllImport("ole32.dll")]
|
||||
static extern int CoCreateInstance(
|
||||
[In] ref Guid rclsid,
|
||||
IntPtr pUnkOuter,
|
||||
int dwClsContext,
|
||||
[In] ref Guid riid,
|
||||
[MarshalAs(UnmanagedType.Interface)] out object ppv);
|
||||
|
||||
// Minimal IShellLinkW — only the methods we call. vtable order matters; the preceding
|
||||
// HRESULT-returning methods are declared with PreserveSig so they stay in slot order
|
||||
// without us needing to handle their results.
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("000214F9-0000-0000-C000-000000000046")]
|
||||
interface IShellLinkW
|
||||
{
|
||||
void GetPath([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszFile,
|
||||
int cch, IntPtr pfd, uint fFlags);
|
||||
void GetIDList(out IntPtr ppidl);
|
||||
void SetIDList(IntPtr pidl);
|
||||
void GetDescription([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszName, int cch);
|
||||
void SetDescription([MarshalAs(UnmanagedType.LPWStr)] string pszName);
|
||||
void GetWorkingDirectory([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszDir, int cch);
|
||||
void SetWorkingDirectory([MarshalAs(UnmanagedType.LPWStr)] string pszDir);
|
||||
void GetArguments([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszArgs, int cch);
|
||||
void SetArguments([MarshalAs(UnmanagedType.LPWStr)] string pszArgs);
|
||||
void GetHotkey(out ushort pwHotkey);
|
||||
void SetHotkey(ushort wHotkey);
|
||||
void GetShowCmd(out int piShowCmd);
|
||||
void SetShowCmd(int iShowCmd);
|
||||
void GetIconLocation([Out, MarshalAs(UnmanagedType.LPWStr)] System.Text.StringBuilder pszIconPath,
|
||||
int cch, out int piIcon);
|
||||
void SetIconLocation([MarshalAs(UnmanagedType.LPWStr)] string pszIconPath, int iIcon);
|
||||
void SetRelativePath([MarshalAs(UnmanagedType.LPWStr)] string pszPathRel, uint dwReserved);
|
||||
void Resolve(IntPtr hwnd, uint fFlags);
|
||||
void SetPath([MarshalAs(UnmanagedType.LPWStr)] string pszFile);
|
||||
}
|
||||
|
||||
[ComImport]
|
||||
[InterfaceType(ComInterfaceType.InterfaceIsIUnknown)]
|
||||
[Guid("0000010B-0000-0000-C000-000000000046")]
|
||||
interface IPersistFile
|
||||
{
|
||||
void GetClassID(out Guid pClassID);
|
||||
void IsDirty();
|
||||
void Load([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, uint dwMode);
|
||||
void Save([MarshalAs(UnmanagedType.LPWStr)] string pszFileName, int remember);
|
||||
void SaveCompleted([MarshalAs(UnmanagedType.LPWStr)] string pszFileName);
|
||||
void GetCurFile([MarshalAs(UnmanagedType.LPWStr)] out string ppszFileName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,512 @@
|
|||
using System;
|
||||
using System.Drawing;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
using AutoIngest.Engine;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// The branding editor: store identity fields, text/logo watermark toggles + settings, and
|
||||
/// EXIF copyright fields, with a live preview rendered via Magick.NET. Shared by the first-run
|
||||
/// WelcomeForm and the tray-opened BrandingForm so the two never duplicate layout. Edits flow
|
||||
/// straight into the bound <see cref="BrandingConfig"/>; the host form calls <see cref="Save"/>
|
||||
/// to persist (or the user can cancel).
|
||||
/// </summary>
|
||||
public class BrandingEditorPanel : UserControl
|
||||
{
|
||||
readonly BrandingConfig _b;
|
||||
readonly AppConfig _config;
|
||||
|
||||
TextBox _storeName = null!, _storeUrl = null!, _handle = null!;
|
||||
CheckBox _textOn = null!, _logoOn = null!, _exifOn = null!;
|
||||
ComboBox _textPos = null!, _logoPos = null!;
|
||||
TrackBar _textOpacity = null!, _logoOpacity = null!, _logoScale = null!;
|
||||
TextBox _logoPath = null!;
|
||||
Button _pickLogo = null!;
|
||||
TextBox _exifArtist = null!, _exifCopyright = null!, _exifDesc = null!;
|
||||
PictureBox _preview = null!;
|
||||
Button _refreshPreview = null!;
|
||||
Label _previewHint = null!;
|
||||
|
||||
// The MemoryStream backing the current _preview.Image. GDI+ Image.FromStream keeps a
|
||||
// reference to the source stream, so it must stay alive for as long as the image is on
|
||||
// screen — we hold it here and dispose it when the next preview replaces it (or when the
|
||||
// panel is disposed). Previously this stream was disposed (via "using var") the moment
|
||||
// RenderPreview returned, which made every preview fail with "Cannot access a closed Stream."
|
||||
MemoryStream? _previewStream;
|
||||
|
||||
public BrandingEditorPanel(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
_b = config.Branding;
|
||||
Dock = DockStyle.Fill;
|
||||
BackColor = Theme.BG;
|
||||
ForeColor = Theme.TEXT;
|
||||
BuildUi();
|
||||
LoadFromConfig();
|
||||
UpdateEnabledStates();
|
||||
}
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
// Drop the live preview image and its backing stream together — GDI+ tied the
|
||||
// image's lifetime to the stream, so both go.
|
||||
_preview.Image?.Dispose();
|
||||
_preview.Image = null;
|
||||
_previewStream?.Dispose();
|
||||
_previewStream = null;
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
void BuildUi()
|
||||
{
|
||||
// Two columns: left = fields, right = preview. Use a top-level TableLayoutPanel.
|
||||
var cols = new TableLayoutPanel
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
ColumnCount = 2,
|
||||
RowCount = 1,
|
||||
BackColor = Theme.BG,
|
||||
Padding = new Padding(12)
|
||||
};
|
||||
cols.ColumnStyles.Add(new ColumnStyle(SizeType.Absolute, 360));
|
||||
cols.ColumnStyles.Add(new ColumnStyle(SizeType.Percent, 100));
|
||||
Controls.Add(cols);
|
||||
|
||||
var leftScroll = new Panel { Dock = DockStyle.Fill, AutoScroll = true, BackColor = Theme.BG };
|
||||
var right = new Panel { Dock = DockStyle.Fill, BackColor = Theme.BG, Padding = new Padding(12, 0, 0, 0) };
|
||||
cols.Controls.Add(leftScroll, 0, 0);
|
||||
cols.Controls.Add(right, 1, 0);
|
||||
|
||||
BuildLeftColumn(leftScroll);
|
||||
BuildRightColumn(right);
|
||||
}
|
||||
|
||||
void BuildLeftColumn(Panel host)
|
||||
{
|
||||
int y = 0;
|
||||
const int RowH = 28;
|
||||
const int LabelW = 130;
|
||||
const int FieldW = 210;
|
||||
|
||||
void AddSection(string title)
|
||||
{
|
||||
var lbl = new Label
|
||||
{
|
||||
Text = title,
|
||||
Location = new Point(0, y),
|
||||
Size = new Size(340, 22),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
host.Controls.Add(lbl);
|
||||
y += 26;
|
||||
}
|
||||
|
||||
Label FieldLabel(string text) => new()
|
||||
{
|
||||
Text = text,
|
||||
Location = new Point(0, y + 3),
|
||||
Size = new Size(LabelW, 20),
|
||||
ForeColor = Theme.TEXT_DIM,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
|
||||
TextBox Field(string placeholder = "")
|
||||
{
|
||||
var tb = new TextBox
|
||||
{
|
||||
Location = new Point(LabelW, y),
|
||||
Size = new Size(FieldW, RowH),
|
||||
BackColor = Theme.BG_DARK,
|
||||
ForeColor = Theme.TEXT,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
PlaceholderText = placeholder
|
||||
};
|
||||
return tb;
|
||||
}
|
||||
|
||||
// --- Store identity -------------------------------------------------------
|
||||
AddSection("Store identity (drives the text watermark)");
|
||||
_storeName = Field("e.g. ReelDeals"); host.Controls.Add(FieldLabel("Store name")); host.Controls.Add(_storeName); y += RowH + 4;
|
||||
_storeUrl = Field("e.g. ebay.com/usr/reeldeals"); host.Controls.Add(FieldLabel("Store URL")); host.Controls.Add(_storeUrl); y += RowH + 4;
|
||||
_handle = Field("e.g. reeldeals"); host.Controls.Add(FieldLabel("Seller handle")); host.Controls.Add(_handle); y += RowH + 10;
|
||||
|
||||
// --- Text watermark -------------------------------------------------------
|
||||
AddSection("Text watermark");
|
||||
_textOn = new CheckBox
|
||||
{
|
||||
Text = "Add text watermark",
|
||||
Location = new Point(0, y),
|
||||
Size = new Size(340, 22),
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
_textOn.CheckedChanged += (_, _) => UpdateEnabledStates();
|
||||
host.Controls.Add(_textOn); y += RowH;
|
||||
|
||||
host.Controls.Add(FieldLabel("Position"));
|
||||
_textPos = PositionCombo();
|
||||
_textPos.Location = new Point(LabelW, y);
|
||||
host.Controls.Add(_textPos); y += RowH + 4;
|
||||
|
||||
host.Controls.Add(FieldLabel($"Opacity ({_b.TextOpacity}%)"));
|
||||
_textOpacity = new TrackBar
|
||||
{
|
||||
Minimum = 5, Maximum = 100,
|
||||
Value = Math.Clamp(_b.TextOpacity, 5, 100),
|
||||
Location = new Point(LabelW, y - 2),
|
||||
Size = new Size(FieldW, 36),
|
||||
TickFrequency = 10
|
||||
};
|
||||
// The label was the last control added; tag the slider with it so the slider can keep
|
||||
// its "(NN%)" text in sync as the user drags.
|
||||
_textOpacity.Tag = host.Controls[host.Controls.Count - 1];
|
||||
_textOpacity.ValueChanged += (_, _) =>
|
||||
{
|
||||
if (_textOpacity.Tag is Label tl)
|
||||
tl.Text = $"Opacity ({_textOpacity.Value}%)";
|
||||
};
|
||||
host.Controls.Add(_textOpacity); y += RowH + 10;
|
||||
|
||||
// --- Logo watermark -------------------------------------------------------
|
||||
AddSection("Logo watermark");
|
||||
_logoOn = new CheckBox
|
||||
{
|
||||
Text = "Add logo watermark",
|
||||
Location = new Point(0, y),
|
||||
Size = new Size(340, 22),
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
_logoOn.CheckedChanged += (_, _) => UpdateEnabledStates();
|
||||
host.Controls.Add(_logoOn); y += RowH;
|
||||
|
||||
host.Controls.Add(FieldLabel("Logo file"));
|
||||
_logoPath = Field("path to a PNG…");
|
||||
_logoPath.ReadOnly = true;
|
||||
_logoPath.Size = new Size(FieldW - 80, RowH);
|
||||
host.Controls.Add(_logoPath);
|
||||
_pickLogo = new Button
|
||||
{
|
||||
Text = "Browse…",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(LabelW + FieldW - 75, y),
|
||||
Size = new Size(75, RowH - 2)
|
||||
};
|
||||
_pickLogo.Click += (_, _) => PickLogo();
|
||||
host.Controls.Add(_pickLogo); y += RowH + 4;
|
||||
|
||||
host.Controls.Add(FieldLabel("Position"));
|
||||
_logoPos = PositionCombo();
|
||||
_logoPos.Location = new Point(LabelW, y);
|
||||
host.Controls.Add(_logoPos); y += RowH + 4;
|
||||
|
||||
host.Controls.Add(FieldLabel($"Scale ({_b.LogoScalePercent}% of width)"));
|
||||
_logoScale = new TrackBar
|
||||
{
|
||||
Minimum = 0, Maximum = 60, Value = Math.Min(60, _b.LogoScalePercent),
|
||||
Location = new Point(LabelW, y - 2),
|
||||
Size = new Size(FieldW, 36),
|
||||
TickFrequency = 10
|
||||
};
|
||||
_logoScale.Tag = host.Controls[host.Controls.Count - 2];
|
||||
_logoScale.ValueChanged += (_, _) =>
|
||||
{
|
||||
if (_logoScale.Tag is Label sl)
|
||||
sl.Text = $"Scale ({_logoScale.Value}% of width)";
|
||||
};
|
||||
host.Controls.Add(_logoScale); y += 34;
|
||||
|
||||
host.Controls.Add(FieldLabel($"Opacity ({_b.LogoOpacity}%)"));
|
||||
_logoOpacity = new TrackBar
|
||||
{
|
||||
Minimum = 5, Maximum = 100, Value = _b.LogoOpacity,
|
||||
Location = new Point(LabelW, y - 2),
|
||||
Size = new Size(FieldW, 36),
|
||||
TickFrequency = 10
|
||||
};
|
||||
_logoOpacity.Tag = host.Controls[host.Controls.Count - 2];
|
||||
_logoOpacity.ValueChanged += (_, _) =>
|
||||
{
|
||||
if (_logoOpacity.Tag is Label ll)
|
||||
ll.Text = $"Opacity ({_logoOpacity.Value}%)";
|
||||
};
|
||||
host.Controls.Add(_logoOpacity); y += 40;
|
||||
|
||||
// --- EXIF copyright -------------------------------------------------------
|
||||
AddSection("EXIF copyright (written after strip)");
|
||||
_exifOn = new CheckBox
|
||||
{
|
||||
Text = "Embed my copyright fields",
|
||||
Location = new Point(0, y),
|
||||
Size = new Size(340, 22),
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
_exifOn.CheckedChanged += (_, _) => UpdateEnabledStates();
|
||||
host.Controls.Add(_exifOn); y += RowH;
|
||||
|
||||
_exifArtist = Field("your name / business"); host.Controls.Add(FieldLabel("Artist")); host.Controls.Add(_exifArtist); y += RowH + 4;
|
||||
_exifCopyright = Field("© 2026 Your Name"); host.Controls.Add(FieldLabel("Copyright")); host.Controls.Add(_exifCopyright); y += RowH + 4;
|
||||
_exifDesc = Field("optional short description"); host.Controls.Add(FieldLabel("Description")); host.Controls.Add(_exifDesc); y += RowH + 10;
|
||||
|
||||
var note = new Label
|
||||
{
|
||||
Location = new Point(0, y),
|
||||
Size = new Size(340, 44),
|
||||
ForeColor = Theme.TEXT_DIM,
|
||||
BackColor = Theme.BG,
|
||||
Text = "Watermarks and EXIF apply only to converted files (RAW/HEIC/etc).\r\nJPGs moved directly pass through untouched."
|
||||
};
|
||||
host.Controls.Add(note);
|
||||
}
|
||||
|
||||
void BuildRightColumn(Panel host)
|
||||
{
|
||||
var lbl = new Label
|
||||
{
|
||||
Text = "Preview",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 24,
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
host.Controls.Add(lbl);
|
||||
|
||||
_preview = new PictureBox
|
||||
{
|
||||
Dock = DockStyle.Fill,
|
||||
BackColor = Theme.BG_DARK,
|
||||
BorderStyle = BorderStyle.FixedSingle,
|
||||
SizeMode = PictureBoxSizeMode.Zoom
|
||||
};
|
||||
host.Controls.Add(_preview);
|
||||
_preview.BringToFront();
|
||||
|
||||
var btnRow = new Panel { Dock = DockStyle.Bottom, Height = 32, BackColor = Theme.BG };
|
||||
_refreshPreview = new Button
|
||||
{
|
||||
Text = "Refresh preview",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Dock = DockStyle.Fill
|
||||
};
|
||||
_refreshPreview.Click += (_, _) => RenderPreview();
|
||||
btnRow.Controls.Add(_refreshPreview);
|
||||
host.Controls.Add(btnRow);
|
||||
|
||||
_previewHint = new Label
|
||||
{
|
||||
Dock = DockStyle.Bottom,
|
||||
Height = 18,
|
||||
ForeColor = Theme.TEXT_DIM,
|
||||
BackColor = Theme.BG,
|
||||
Text = "Preview renders on a sample image.",
|
||||
TextAlign = ContentAlignment.MiddleCenter
|
||||
};
|
||||
host.Controls.Add(_previewHint);
|
||||
}
|
||||
|
||||
ComboBox PositionCombo()
|
||||
{
|
||||
var cb = new ComboBox
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Size = new Size(130, 22)
|
||||
};
|
||||
foreach (var name in Enum.GetNames<WatermarkPosition>())
|
||||
cb.Items.Add(name);
|
||||
return cb;
|
||||
}
|
||||
|
||||
|
||||
void UpdateEnabledStates()
|
||||
{
|
||||
_textPos.Enabled = _textOn.Checked;
|
||||
_textOpacity.Enabled = _textOn.Checked;
|
||||
_logoPath.Enabled = _logoOn.Checked;
|
||||
_pickLogo.Enabled = _logoOn.Checked;
|
||||
_logoPos.Enabled = _logoOn.Checked;
|
||||
_logoScale.Enabled = _logoOn.Checked;
|
||||
_logoOpacity.Enabled = _logoOn.Checked;
|
||||
_exifArtist.Enabled = _exifOn.Checked;
|
||||
_exifCopyright.Enabled = _exifOn.Checked;
|
||||
_exifDesc.Enabled = _exifOn.Checked;
|
||||
}
|
||||
|
||||
void PickLogo()
|
||||
{
|
||||
using var dlg = new OpenFileDialog
|
||||
{
|
||||
Title = "Choose a logo (PNG with transparency recommended)",
|
||||
Filter = "Images|*.png;*.jpg;*.jpeg;*.bmp|All files|*.*"
|
||||
};
|
||||
if (dlg.ShowDialog(FindForm()) == DialogResult.OK)
|
||||
_logoPath.Text = dlg.FileName;
|
||||
}
|
||||
|
||||
void LoadFromConfig()
|
||||
{
|
||||
_storeName.Text = _b.StoreName;
|
||||
_storeUrl.Text = _b.StoreUrl;
|
||||
_handle.Text = _b.SellerHandle;
|
||||
|
||||
_textOn.Checked = _b.TextWatermarkEnabled;
|
||||
SelectByName(_textPos, _b.TextPosition);
|
||||
_textOpacity.Value = ClampTrack(_textOpacity, _b.TextOpacity);
|
||||
|
||||
_logoOn.Checked = _b.LogoWatermarkEnabled;
|
||||
_logoPath.Text = _b.LogoPath;
|
||||
SelectByName(_logoPos, _b.LogoPosition);
|
||||
_logoScale.Value = ClampTrack(_logoScale, _b.LogoScalePercent);
|
||||
_logoOpacity.Value = ClampTrack(_logoOpacity, _b.LogoOpacity);
|
||||
|
||||
_exifOn.Checked = _b.ExifCopyrightEnabled;
|
||||
_exifArtist.Text = _b.ExifArtist;
|
||||
_exifCopyright.Text = _b.ExifCopyright;
|
||||
_exifDesc.Text = _b.ExifDescription;
|
||||
}
|
||||
|
||||
/// <summary>Writes the current UI state back into the bound config (does not persist).</summary>
|
||||
public void ApplyToConfig()
|
||||
{
|
||||
_b.StoreName = _storeName.Text.Trim();
|
||||
_b.StoreUrl = _storeUrl.Text.Trim();
|
||||
_b.SellerHandle = _handle.Text.Trim();
|
||||
|
||||
_b.TextWatermarkEnabled = _textOn.Checked;
|
||||
if (_textPos.SelectedItem is string tp && Enum.TryParse<WatermarkPosition>(tp, out var tpos))
|
||||
_b.TextPosition = tpos;
|
||||
_b.TextOpacity = _textOpacity.Value;
|
||||
|
||||
_b.LogoWatermarkEnabled = _logoOn.Checked;
|
||||
_b.LogoPath = _logoPath.Text.Trim();
|
||||
if (_logoPos.SelectedItem is string lp && Enum.TryParse<WatermarkPosition>(lp, out var lpos))
|
||||
_b.LogoPosition = lpos;
|
||||
_b.LogoScalePercent = _logoScale.Value;
|
||||
_b.LogoOpacity = _logoOpacity.Value;
|
||||
|
||||
_b.ExifCopyrightEnabled = _exifOn.Checked;
|
||||
_b.ExifArtist = _exifArtist.Text.Trim();
|
||||
_b.ExifCopyright = _exifCopyright.Text.Trim();
|
||||
_b.ExifDescription = _exifDesc.Text.Trim();
|
||||
|
||||
// Master switch: branding is "enabled" if any of the three features are on. This keeps
|
||||
// the semantics simple — turning everything off disables branding entirely.
|
||||
_b.Enabled = _b.TextWatermarkEnabled || _b.LogoWatermarkEnabled || _b.ExifCopyrightEnabled;
|
||||
}
|
||||
|
||||
/// <summary>Persists config to disk.</summary>
|
||||
public void Save()
|
||||
{
|
||||
ApplyToConfig();
|
||||
ConfigManager.SaveConfig(_config);
|
||||
}
|
||||
|
||||
public void RenderPreview()
|
||||
{
|
||||
ApplyToConfig();
|
||||
// Render on a background thread so the UI doesn't freeze on large Magick.NET ops.
|
||||
var hint = _previewHint;
|
||||
hint.Text = "Rendering preview…";
|
||||
System.Threading.Tasks.Task.Run(() =>
|
||||
{
|
||||
// Ownership of the returned stream transfers to us. We must NOT "using" it here —
|
||||
// GDI+ keeps the stream open for the image's lifetime, so we hand it to
|
||||
// _previewStream on the UI thread and let it live until the next preview replaces
|
||||
// it (or Dispose runs).
|
||||
MemoryStream? newStream;
|
||||
Image? img;
|
||||
try
|
||||
{
|
||||
newStream = RenderSamplePreview(_b);
|
||||
img = Image.FromStream(newStream);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Render/decode failed — tell the UI if it's still around. UiInvokeIfAlive
|
||||
// tolerates the panel being disposed (e.g. the user closed the form while we
|
||||
// were rendering); a plain this.Invoke here would cascade the failure into an
|
||||
// unhandled crash.
|
||||
var msg = ex.Message;
|
||||
UiInvokeIfAlive(() => hint.Text = $"Preview failed: {msg}");
|
||||
return;
|
||||
}
|
||||
|
||||
// Hand the live stream + image to the UI thread. Returns false if the panel is gone
|
||||
// (form closed mid-render) — in that case drop both locally rather than marshaling
|
||||
// into a disposed control and leaking the stream.
|
||||
if (!UiInvokeIfAlive(() =>
|
||||
{
|
||||
_preview.Image?.Dispose();
|
||||
_previewStream?.Dispose();
|
||||
_previewStream = newStream;
|
||||
_preview.Image = img;
|
||||
hint.Text = "Preview rendered on a sample image.";
|
||||
}))
|
||||
{
|
||||
img.Dispose();
|
||||
newStream.Dispose();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>Marshals an action onto the control's UI thread, tolerating the control being
|
||||
/// closed/disposed before the background render finishes. RenderPreview runs on a Task.Run;
|
||||
/// if the form closes (Save/Cancel) while Magick is still rendering, the window handle is
|
||||
/// gone by the time we marshal back. A bare Invoke throws InvalidOperationException there
|
||||
/// and — because it's called from the catch path too — would crash the app. This helper
|
||||
/// returns true if the action ran, false if the control is gone.</summary>
|
||||
bool UiInvokeIfAlive(Action action)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated) return false;
|
||||
try { Invoke(action); return true; }
|
||||
// ObjectDisposedException derives from InvalidOperationException, so this covers both
|
||||
// the "handle destroyed between the check and Invoke" race and an outright disposal.
|
||||
catch (InvalidOperationException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>Renders a small sample image with the current branding applied and returns it
|
||||
/// as PNG bytes in a fresh MemoryStream. Ownership of the stream transfers to the caller;
|
||||
/// it is NOT disposed here (a previous "using var ms" + "return ms" returned an already-
|
||||
/// disposed stream). The caller must keep the stream open for as long as any Image created
|
||||
/// from it is in use.</summary>
|
||||
static MemoryStream RenderSamplePreview(BrandingConfig b)
|
||||
{
|
||||
// Build a synthetic photo-like background so the preview is representative without
|
||||
// shipping a sample asset. A soft gradient with a mid-tone works well.
|
||||
using var image = new ImageMagick.MagickImage(new ImageMagick.MagickColor("#3a3f4b"), 640, 400);
|
||||
// Apply branding the same way ConvertToJpg does. BrandingRenderer.Apply handles
|
||||
// disabled/no-op cases internally.
|
||||
BrandingRenderer.Apply(image, b);
|
||||
// No "using" — the caller owns this stream and needs it alive for the Image's lifetime.
|
||||
var ms = new MemoryStream();
|
||||
image.Write(ms, ImageMagick.MagickFormat.Png);
|
||||
ms.Position = 0;
|
||||
return ms;
|
||||
}
|
||||
|
||||
static void SelectByName(ComboBox cb, WatermarkPosition pos)
|
||||
{
|
||||
string name = pos.ToString();
|
||||
for (int i = 0; i < cb.Items.Count; i++)
|
||||
if (cb.Items[i] is string s && s == name) { cb.SelectedIndex = i; return; }
|
||||
if (cb.Items.Count > 0) cb.SelectedIndex = 0;
|
||||
}
|
||||
|
||||
static int ClampTrack(TrackBar tb, int v) =>
|
||||
v < tb.Minimum ? tb.Minimum : v > tb.Maximum ? tb.Maximum : v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,85 @@
|
|||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Branding editor opened from the tray (Options ▸ Branding ▸ Edit branding…). Hosts the
|
||||
/// shared <see cref="BrandingEditorPanel"/> with Save / Cancel. The panel edits the live
|
||||
/// config object; Save persists, Cancel reverts by reloading.
|
||||
/// </summary>
|
||||
public class BrandingForm : Form
|
||||
{
|
||||
readonly AppConfig _config;
|
||||
BrandingEditorPanel _panel = null!;
|
||||
|
||||
public BrandingForm(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
BuildUi();
|
||||
}
|
||||
|
||||
void BuildUi()
|
||||
{
|
||||
Text = "AutoIngest — Branding";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(760, 620);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
BackColor = Theme.BG;
|
||||
ForeColor = Theme.TEXT;
|
||||
Font = new Font("Segoe UI", 9F);
|
||||
|
||||
_panel = new BrandingEditorPanel(_config);
|
||||
Controls.Add(_panel);
|
||||
|
||||
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 40, BackColor = Theme.BG };
|
||||
Controls.Add(bottom);
|
||||
|
||||
var save = new Button
|
||||
{
|
||||
Text = "Save",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(ClientSize.Width - 200, 8),
|
||||
Size = new Size(85, 26)
|
||||
};
|
||||
save.Click += (_, _) =>
|
||||
{
|
||||
_panel.Save();
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
};
|
||||
|
||||
var cancel = new Button
|
||||
{
|
||||
Text = "Cancel",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(ClientSize.Width - 105, 8),
|
||||
Size = new Size(85, 26)
|
||||
};
|
||||
cancel.Click += (_, _) =>
|
||||
{
|
||||
DialogResult = DialogResult.Cancel;
|
||||
Close();
|
||||
};
|
||||
|
||||
bottom.Controls.Add(save);
|
||||
bottom.Controls.Add(cancel);
|
||||
AcceptButton = save;
|
||||
CancelButton = cancel;
|
||||
|
||||
// Kick off an initial preview once the form is on screen. This MUST run after the
|
||||
// window handle exists — BeginInvoke/Invoke throw before then, so doing it in the
|
||||
// constructor (where the handle isn't created yet) crashes the form on open. Load
|
||||
// fires once the handle is created but before first paint.
|
||||
Load += (_, _) => _panel.RenderPreview();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Tiny borderless toast popup. Lives in the tray by default; only shown briefly
|
||||
/// while an SD/USB import is running (and a few seconds after it finishes).
|
||||
/// Canonical serialized InitializeComponent so the WinForms Designer can open it.
|
||||
/// </summary>
|
||||
partial class MainForm
|
||||
{
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
{
|
||||
_trayBitmap?.Dispose();
|
||||
_hideTimer?.Dispose();
|
||||
_coordinator?.Dispose();
|
||||
components?.Dispose();
|
||||
}
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.pnlToast = new System.Windows.Forms.Panel();
|
||||
this.txtLog = new System.Windows.Forms.RichTextBox();
|
||||
this.lblStatus = new System.Windows.Forms.Label();
|
||||
this.lblTitle = new System.Windows.Forms.Label();
|
||||
this.pnlToast.SuspendLayout();
|
||||
this.SuspendLayout();
|
||||
//
|
||||
// pnlToast
|
||||
//
|
||||
this.pnlToast.BackColor = System.Drawing.Color.FromArgb(30, 30, 30);
|
||||
this.pnlToast.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
|
||||
this.pnlToast.Controls.Add(this.txtLog);
|
||||
this.pnlToast.Controls.Add(this.lblStatus);
|
||||
this.pnlToast.Controls.Add(this.lblTitle);
|
||||
this.pnlToast.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.pnlToast.Location = new System.Drawing.Point(0, 0);
|
||||
this.pnlToast.Name = "pnlToast";
|
||||
this.pnlToast.Size = new System.Drawing.Size(340, 170);
|
||||
this.pnlToast.TabIndex = 0;
|
||||
//
|
||||
// txtLog
|
||||
//
|
||||
this.txtLog.BackColor = System.Drawing.Color.FromArgb(20, 20, 20);
|
||||
this.txtLog.BorderStyle = System.Windows.Forms.BorderStyle.None;
|
||||
this.txtLog.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
this.txtLog.Font = new System.Drawing.Font("Consolas", 8F);
|
||||
this.txtLog.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224);
|
||||
this.txtLog.Location = new System.Drawing.Point(0, 48);
|
||||
this.txtLog.Name = "txtLog";
|
||||
this.txtLog.ReadOnly = true;
|
||||
this.txtLog.ScrollBars = System.Windows.Forms.RichTextBoxScrollBars.Vertical;
|
||||
this.txtLog.Size = new System.Drawing.Size(338, 121);
|
||||
this.txtLog.TabIndex = 2;
|
||||
this.txtLog.Text = "";
|
||||
//
|
||||
// lblStatus
|
||||
//
|
||||
this.lblStatus.BackColor = System.Drawing.Color.FromArgb(30, 30, 30);
|
||||
this.lblStatus.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.lblStatus.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
|
||||
this.lblStatus.ForeColor = System.Drawing.Color.FromArgb(88, 166, 255);
|
||||
this.lblStatus.Location = new System.Drawing.Point(0, 24);
|
||||
this.lblStatus.Name = "lblStatus";
|
||||
this.lblStatus.Padding = new System.Windows.Forms.Padding(10, 2, 10, 2);
|
||||
this.lblStatus.Size = new System.Drawing.Size(338, 24);
|
||||
this.lblStatus.TabIndex = 1;
|
||||
this.lblStatus.Text = "\u25CF Idle \u2014 insert an SD card or USB drive";
|
||||
this.lblStatus.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
|
||||
//
|
||||
// lblTitle
|
||||
//
|
||||
this.lblTitle.BackColor = System.Drawing.Color.FromArgb(42, 42, 42);
|
||||
this.lblTitle.Dock = System.Windows.Forms.DockStyle.Top;
|
||||
this.lblTitle.Font = new System.Drawing.Font("Segoe UI", 8.25F, System.Drawing.FontStyle.Bold);
|
||||
this.lblTitle.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224);
|
||||
this.lblTitle.Location = new System.Drawing.Point(0, 0);
|
||||
this.lblTitle.Name = "lblTitle";
|
||||
this.lblTitle.Padding = new System.Windows.Forms.Padding(10, 4, 10, 4);
|
||||
this.lblTitle.Size = new System.Drawing.Size(338, 24);
|
||||
this.lblTitle.TabIndex = 0;
|
||||
this.lblTitle.Text = "AutoIngest";
|
||||
this.lblTitle.TextAlign = System.Drawing.ContentAlignment.MiddleLeft;
|
||||
//
|
||||
// MainForm
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.BackColor = System.Drawing.Color.FromArgb(30, 30, 30);
|
||||
this.ClientSize = new System.Drawing.Size(340, 170);
|
||||
this.Controls.Add(this.pnlToast);
|
||||
this.ForeColor = System.Drawing.Color.FromArgb(224, 224, 224);
|
||||
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.None;
|
||||
this.MaximizeBox = false;
|
||||
this.MinimizeBox = false;
|
||||
this.Name = "MainForm";
|
||||
this.ShowInTaskbar = false;
|
||||
this.StartPosition = System.Windows.Forms.FormStartPosition.Manual;
|
||||
this.Text = "AutoIngest";
|
||||
this.TopMost = true;
|
||||
this.pnlToast.ResumeLayout(false);
|
||||
this.ResumeLayout(false);
|
||||
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
private System.Windows.Forms.Panel pnlToast;
|
||||
private System.Windows.Forms.Label lblTitle;
|
||||
private System.Windows.Forms.Label lblStatus;
|
||||
private System.Windows.Forms.RichTextBox txtLog;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,730 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
using AutoIngest.Engine;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Tray-resident photo importer. The window is a small borderless toast that stays
|
||||
/// hidden by default and only pops into the bottom-right corner of the screen while
|
||||
/// an SD/USB import is running (and for a few seconds after it finishes).
|
||||
///
|
||||
/// Static UI lives in MainForm.Designer.cs; this file holds the toast show/hide
|
||||
/// logic, tray icon, monitor wiring, and config.
|
||||
/// </summary>
|
||||
public partial class MainForm : Form
|
||||
{
|
||||
#region theme
|
||||
// Aliases over the shared Theme palette so this file keeps its existing CLR_*
|
||||
// references while SettingsForm and any future UI use Theme.* directly.
|
||||
static readonly Color CLR_BG = Theme.BG;
|
||||
static readonly Color CLR_BG2 = Theme.BG2;
|
||||
static readonly Color CLR_TEXT = Theme.TEXT;
|
||||
static readonly Color CLR_TEXT_DIM = Theme.TEXT_DIM;
|
||||
static readonly Color CLR_ACCENT = Theme.ACCENT;
|
||||
static readonly Color CLR_GREEN = Theme.GREEN;
|
||||
static readonly Color CLR_RED = Theme.RED;
|
||||
#endregion
|
||||
|
||||
#region fields
|
||||
ImportCoordinator? _coordinator;
|
||||
DeviceRegistry? _registry;
|
||||
readonly AutoIngest.Engine.ImageConverter _converter = null!;
|
||||
AppConfig _config = null!;
|
||||
bool _firstLaunch;
|
||||
|
||||
// Runtime-only UI (not Designer-serialized).
|
||||
NotifyIcon trayIcon = null!;
|
||||
ContextMenuStrip trayMenu = null!;
|
||||
Bitmap? _trayBitmap;
|
||||
|
||||
/// <summary>Auto-hides the toast a few seconds after the last activity.</summary>
|
||||
System.Windows.Forms.Timer? _hideTimer;
|
||||
|
||||
/// <summary>True while an import is actively running — keeps the toast pinned open.</summary>
|
||||
bool _importing;
|
||||
|
||||
const int HIDE_DELAY_MS = 4000;
|
||||
const string APP_NAME = "AutoIngest";
|
||||
#endregion
|
||||
|
||||
public MainForm()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
// Let the WinForms Designer instantiate the form without running
|
||||
// runtime-only initialization (config load, monitor, file I/O, etc.).
|
||||
if (LicenseManager.UsageMode == LicenseUsageMode.Designtime)
|
||||
return;
|
||||
|
||||
_converter = new AutoIngest.Engine.ImageConverter();
|
||||
// Snapshot whether this is a brand-new install BEFORE LoadConfig (which is a read and
|
||||
// doesn't create the file, but capturing explicitly is clearer and future-proof).
|
||||
_firstLaunch = !ConfigManager.ConfigExists;
|
||||
_config = ConfigManager.LoadConfig();
|
||||
|
||||
_hideTimer = new System.Windows.Forms.Timer { Interval = HIDE_DELAY_MS };
|
||||
_hideTimer.Tick += (_, _) =>
|
||||
{
|
||||
_hideTimer.Stop();
|
||||
if (!_importing) Hide();
|
||||
};
|
||||
|
||||
BuildTrayIcon();
|
||||
WireMonitorEvents();
|
||||
|
||||
Shown += (_, _) =>
|
||||
{
|
||||
// The very first Shown fires after Application.Run creates the form.
|
||||
// We want to start hidden, so immediately hide (the form never had to
|
||||
// be visible in the first place — but WinForms shows it once on Run).
|
||||
Hide();
|
||||
|
||||
// First-ever install: run the welcome/branding wizard once before starting the
|
||||
// monitor. Existing users (config already on disk) skip straight through.
|
||||
if (_firstLaunch)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var wiz = new WelcomeForm(_config);
|
||||
wiz.ShowDialog(this);
|
||||
// Branding may have changed during the wizard — refresh the tray icon so
|
||||
// it shows the user's mark immediately rather than on next launch.
|
||||
RefreshTrayIcon();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
// Never let a wizard failure block startup — log and continue with defaults.
|
||||
Log($"Setup wizard skipped: {ex.Message}", CLR_RED);
|
||||
_config.SetupCompleted = true;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
}
|
||||
}
|
||||
|
||||
StartMonitor();
|
||||
};
|
||||
|
||||
FormClosing += MainForm_FormClosing;
|
||||
|
||||
// Start hidden — tray only.
|
||||
Visible = false;
|
||||
WindowState = FormWindowState.Minimized;
|
||||
}
|
||||
|
||||
#region startup / monitor
|
||||
void StartMonitor()
|
||||
{
|
||||
_registry = new DeviceRegistry(_config);
|
||||
|
||||
var importer = new DeviceImporter(_converter, _config);
|
||||
var sources = new List<IDeviceSource>
|
||||
{
|
||||
new SDCardMonitor(),
|
||||
new MtpDeviceSource(() => _config.DeleteFromPhoneAfterImport)
|
||||
};
|
||||
|
||||
_coordinator = new ImportCoordinator(importer, _registry, _config, sources);
|
||||
WireRuntimeMonitorEvents();
|
||||
_coordinator.ResolveUnknownDevice += ResolveUnknownDevice;
|
||||
|
||||
if (_config.AutoImport)
|
||||
_coordinator.Start();
|
||||
|
||||
Log("AutoIngest ready.", CLR_ACCENT);
|
||||
if (_registry!.All.Count > 0)
|
||||
Log($"{_registry.All.Count} device(s) registered. Insert one to begin.", CLR_TEXT_DIM);
|
||||
else
|
||||
Log("No devices registered yet. Insert a device to approve it.", CLR_TEXT_DIM);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Called on the coordinator thread when an unregistered device is connected. We marshal
|
||||
/// to the UI thread and ask the user: register + import, import once, or ignore. If the
|
||||
/// prompt is disabled in settings, the coordinator never calls this — we silently skip.
|
||||
/// </summary>
|
||||
UnknownDeviceResolution ResolveUnknownDevice(DeviceIdentity device)
|
||||
{
|
||||
if (InvokeRequired)
|
||||
{
|
||||
return (UnknownDeviceResolution)Invoke(new Func<DeviceIdentity, UnknownDeviceResolution>(ResolveUnknownDevice), device);
|
||||
}
|
||||
|
||||
// Show a brief toast so the user sees something happened even if they're not looking
|
||||
// at a dialog, then prompt.
|
||||
lblStatus.Text = $"\u25CF New device: {device.DisplayName}";
|
||||
lblStatus.ForeColor = CLR_ACCENT;
|
||||
ShowToast();
|
||||
ArmAutoHide();
|
||||
|
||||
string kind = device.Kind.Contains("weak", StringComparison.OrdinalIgnoreCase)
|
||||
? $"{device.Kind} — id may be unreliable"
|
||||
: device.Kind;
|
||||
|
||||
var result = MessageBox.Show(this,
|
||||
$"A new device was connected:\n\n" +
|
||||
$" {device.DisplayName}\n" +
|
||||
$" Type: {kind}\n\n" +
|
||||
"Register it so it imports automatically from now on?\n\n" +
|
||||
" Yes = Register & import\n" +
|
||||
" No = Import this once only\n" +
|
||||
" Cancel = Ignore this session",
|
||||
"New device — AutoIngest",
|
||||
MessageBoxButtons.YesNoCancel,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
|
||||
return result switch
|
||||
{
|
||||
DialogResult.Yes => UnknownDeviceResolution.RegisterAndImport,
|
||||
DialogResult.No => UnknownDeviceResolution.ImportOnce,
|
||||
_ => UnknownDeviceResolution.Ignore,
|
||||
};
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region toast show / hide
|
||||
const int TOAST_MARGIN = 12;
|
||||
|
||||
/// <summary>
|
||||
/// Positions the toast at the bottom-right of the working area (just above the
|
||||
/// taskbar, clear of the screen edges on every monitor / DPI scale) and shows it.
|
||||
/// Uses SetBounds so x/y and the rendered size are applied atomically, then clamps
|
||||
/// inside the working area so the form is always fully on-screen.
|
||||
/// </summary>
|
||||
void ShowToast()
|
||||
{
|
||||
// Make sure the form has a real size before we measure it. On the first show
|
||||
// the layout may not have run yet, so force it.
|
||||
if (Width == 0 || Height == 0)
|
||||
{
|
||||
PerformLayout();
|
||||
var pref = PreferredSize;
|
||||
if (Width == 0) Width = pref.Width;
|
||||
if (Height == 0) Height = pref.Height;
|
||||
}
|
||||
|
||||
// GetWorkingArea(this) resolves the actual screen the form is on and accounts
|
||||
// for the taskbar; fall back to the primary screen's working area.
|
||||
var area = Screen.GetWorkingArea(this);
|
||||
if (area.IsEmpty) area = Screen.PrimaryScreen?.WorkingArea ?? SystemInformation.WorkingArea;
|
||||
|
||||
int w = Width;
|
||||
int h = Height;
|
||||
|
||||
// Target the bottom-right corner with a margin, then clamp into the working
|
||||
// area so rounding / DPI scaling can never push part of it off-screen.
|
||||
int x = area.Right - w - TOAST_MARGIN;
|
||||
int y = area.Bottom - h - TOAST_MARGIN;
|
||||
if (x < area.Left + TOAST_MARGIN) x = area.Left + TOAST_MARGIN;
|
||||
if (y < area.Top + TOAST_MARGIN) y = area.Top + TOAST_MARGIN;
|
||||
if (x + w > area.Right - TOAST_MARGIN) x = area.Right - w - TOAST_MARGIN;
|
||||
if (y + h > area.Bottom - TOAST_MARGIN) y = area.Bottom - h - TOAST_MARGIN;
|
||||
|
||||
_hideTimer?.Stop();
|
||||
if (!Visible)
|
||||
{
|
||||
// SetBounds applies position + size atomically before the window is shown,
|
||||
// so it appears exactly where we want it with no flicker or repositioning.
|
||||
SetBounds(x, y, w, h);
|
||||
Show();
|
||||
WindowState = FormWindowState.Normal;
|
||||
}
|
||||
else
|
||||
{
|
||||
SetBounds(x, y, w, h);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Arm the auto-hide timer. Called after any activity; if no further activity
|
||||
/// arrives within HIDE_DELAY_MS the toast hides itself (unless an import is
|
||||
/// actively running).
|
||||
/// </summary>
|
||||
void ArmAutoHide()
|
||||
{
|
||||
_hideTimer?.Stop();
|
||||
_hideTimer?.Start();
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region monitor events
|
||||
// Wired at design time (cheap — just lambda closures).
|
||||
void WireMonitorEvents() { }
|
||||
|
||||
// Wired once the coordinator exists (after StartMonitor creates it). These passthrough
|
||||
// events come from DeviceImporter via ImportCoordinator — same shape as the old monitor.
|
||||
void WireRuntimeMonitorEvents()
|
||||
{
|
||||
var monitor = _coordinator!;
|
||||
|
||||
monitor.DriveFound += (driveId, label) =>
|
||||
{
|
||||
_importing = true;
|
||||
SafeInvoke(() =>
|
||||
{
|
||||
lblStatus.Text = $"\u25CF Reading {label}";
|
||||
lblStatus.ForeColor = CLR_ACCENT;
|
||||
ShowToast();
|
||||
});
|
||||
Log($"Drive found: {label}", CLR_ACCENT);
|
||||
};
|
||||
|
||||
monitor.ImportProgress += (current, total, filename) =>
|
||||
{
|
||||
SafeInvoke(() =>
|
||||
{
|
||||
lblStatus.Text = $"\u25CF {current}/{total}: {filename}";
|
||||
ShowToast();
|
||||
});
|
||||
};
|
||||
|
||||
monitor.ImportDone += (moved, converted, sizeStr, destFolder) =>
|
||||
{
|
||||
_importing = false;
|
||||
SafeInvoke(() =>
|
||||
{
|
||||
lblStatus.Text = $"\u2713 Done \u2014 {moved} moved, {converted} converted ({sizeStr})";
|
||||
lblStatus.ForeColor = CLR_GREEN;
|
||||
ShowToast();
|
||||
ArmAutoHide();
|
||||
});
|
||||
Log($"Import complete: {moved} moved, {converted} converted, {sizeStr} total -> {destFolder}", CLR_GREEN);
|
||||
};
|
||||
|
||||
monitor.ImportError += msg =>
|
||||
{
|
||||
// An error terminates the active import from the UI's perspective — without this
|
||||
// the toast would stay pinned open (the hide timer checks _importing).
|
||||
_importing = false;
|
||||
SafeInvoke(() =>
|
||||
{
|
||||
ShowToast();
|
||||
ArmAutoHide();
|
||||
});
|
||||
Log($"Error: {msg}", CLR_RED);
|
||||
};
|
||||
|
||||
monitor.LogMessage += (msg, color) =>
|
||||
{
|
||||
var c = ParseHexColor(color) ?? CLR_TEXT;
|
||||
Log(msg, c);
|
||||
};
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Marshals an action to the UI thread, but safely: if the form (or its handle) is gone —
|
||||
/// which can happen during shutdown while a coordinator/retention background callback is
|
||||
/// still in flight — the call is dropped rather than throwing ObjectDisposedException /
|
||||
/// InvalidOperationException up the background thread and crashing. Also guards against
|
||||
/// the rare InvokeRequired-on-no-handle case.
|
||||
/// </summary>
|
||||
void SafeInvoke(Action action)
|
||||
{
|
||||
if (IsDisposed || !IsHandleCreated) return;
|
||||
try
|
||||
{
|
||||
if (InvokeRequired) Invoke(action);
|
||||
else action();
|
||||
}
|
||||
catch (ObjectDisposedException) { /* control gone */ }
|
||||
catch (InvalidOperationException) { /* form disposing/disposed */ }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region log
|
||||
void Log(string msg, Color color)
|
||||
{
|
||||
string line = $"[{DateTime.Now:HH:mm:ss}] {msg}\n";
|
||||
// Mirror to the persistent file log so a "photos disappeared" report is diagnosable
|
||||
// after the toast log has vanished on restart.
|
||||
FileLogger.Log(msg);
|
||||
if (txtLog.InvokeRequired)
|
||||
txtLog.Invoke(new Action(() => AppendLog(line, color)));
|
||||
else
|
||||
AppendLog(line, color);
|
||||
}
|
||||
|
||||
void AppendLog(string text, Color color)
|
||||
{
|
||||
txtLog.SelectionStart = txtLog.TextLength;
|
||||
txtLog.SelectionColor = color;
|
||||
txtLog.AppendText(text);
|
||||
txtLog.SelectionColor = CLR_TEXT;
|
||||
txtLog.ScrollToCaret();
|
||||
}
|
||||
|
||||
static Color? ParseHexColor(string hex)
|
||||
{
|
||||
if (hex.Length < 7 || hex[0] != '#') return null;
|
||||
try
|
||||
{
|
||||
int r = int.Parse(hex[1..3], NumberStyles.HexNumber);
|
||||
int g = int.Parse(hex[3..5], NumberStyles.HexNumber);
|
||||
int b = int.Parse(hex[5..7], NumberStyles.HexNumber);
|
||||
return Color.FromArgb(r, g, b);
|
||||
}
|
||||
catch (FormatException) { return null; }
|
||||
catch (OverflowException) { return null; }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region tray icon
|
||||
void BuildTrayIcon()
|
||||
{
|
||||
trayMenu = new ContextMenuStrip
|
||||
{
|
||||
BackColor = CLR_BG2,
|
||||
ForeColor = CLR_TEXT,
|
||||
Renderer = new DarkMenuRenderer()
|
||||
};
|
||||
|
||||
var showItem = new ToolStripMenuItem("Show status");
|
||||
showItem.Click += (_, _) => { ShowToast(); };
|
||||
var folderItem = new ToolStripMenuItem("Open import folder");
|
||||
folderItem.Click += (_, _) => OpenImportFolder();
|
||||
var devicesItem = new ToolStripMenuItem("Settings…");
|
||||
devicesItem.Click += (_, _) => OpenSettings();
|
||||
var resetItem = new ToolStripMenuItem("Reset drive memory");
|
||||
resetItem.Click += (_, _) =>
|
||||
{
|
||||
_coordinator?.ResetMemory();
|
||||
Log("Drive memory cleared. All devices will be re-scanned.", CLR_ACCENT);
|
||||
};
|
||||
|
||||
// Options submenu: feature toggles. Each is a checked item bound to a config field,
|
||||
// saved immediately on flip, and read live by the importer so the change applies to
|
||||
// the very next import without a restart. New one-off toggles slot in here.
|
||||
var optionsItem = new ToolStripMenuItem("Options");
|
||||
|
||||
var autoOrientItem = new ToolStripMenuItem("Auto-orient (EXIF)")
|
||||
{
|
||||
Checked = _config.AutoOrient,
|
||||
CheckOnClick = true
|
||||
};
|
||||
autoOrientItem.Click += (_, _) =>
|
||||
{
|
||||
_config.AutoOrient = autoOrientItem.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
Log($"Auto-orient {(autoOrientItem.Checked ? "enabled" : "disabled")} — applies to next import.",
|
||||
CLR_ACCENT);
|
||||
};
|
||||
|
||||
var stripExifItem = new ToolStripMenuItem("Strip metadata (EXIF/GPS)")
|
||||
{
|
||||
Checked = _config.StripExif,
|
||||
CheckOnClick = true
|
||||
};
|
||||
stripExifItem.Click += (_, _) =>
|
||||
{
|
||||
_config.StripExif = stripExifItem.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
Log($"Metadata stripping {(stripExifItem.Checked ? "enabled" : "disabled")} — applies to next import.",
|
||||
CLR_ACCENT);
|
||||
};
|
||||
|
||||
// Delete-from-phone toggle: phones only. SD/USB always moved/converted (their originals
|
||||
// always go). Phones default to copy-and-leave so the user curates phone storage; this
|
||||
// makes a phone behave like a card — original deleted after the local copy verifies.
|
||||
// Turning ON requires explicit confirmation because phone-side deletion is irreversible
|
||||
// (there is no Recycle Bin over MTP). Decline → revert the checkbox, leave config off.
|
||||
var phoneDeleteItem = new ToolStripMenuItem("Delete from phone after import")
|
||||
{
|
||||
Checked = _config.DeleteFromPhoneAfterImport,
|
||||
CheckOnClick = true
|
||||
};
|
||||
phoneDeleteItem.Click += (_, _) =>
|
||||
{
|
||||
if (phoneDeleteItem.Checked && !_config.DeleteFromPhoneAfterImport)
|
||||
{
|
||||
var confirm = MessageBox.Show(this,
|
||||
"Delete from phone after import?\n\n" +
|
||||
"When this is on, a phone's photo originals are PERMANENTLY removed after the " +
|
||||
"local copy is verified. Phones have no Recycle Bin over MTP — this cannot be undone.\n\n" +
|
||||
"SD/USB cards are unaffected (they always moved).\n\n" +
|
||||
"Enable anyway?",
|
||||
"Delete from phone after import",
|
||||
MessageBoxButtons.YesNo, MessageBoxIcon.Warning);
|
||||
if (confirm != DialogResult.Yes)
|
||||
{
|
||||
phoneDeleteItem.Checked = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
_config.DeleteFromPhoneAfterImport = phoneDeleteItem.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
Log($"Phone delete {(phoneDeleteItem.Checked ? "enabled" : "disabled")} — applies to next phone import. SD/USB unaffected.",
|
||||
CLR_ACCENT);
|
||||
};
|
||||
|
||||
// Branding submenu: a master toggle plus an editor entry. Branding covers text/logo
|
||||
// watermarks + EXIF copyright, which are too dense for bare toggles, so the editor
|
||||
// opens the BrandingForm. The toggle reflects the master switch (Branding.Enabled),
|
||||
// which is on when any branding feature is configured.
|
||||
var brandingItem = new ToolStripMenuItem("Branding");
|
||||
var brandingToggle = new ToolStripMenuItem("Enabled")
|
||||
{
|
||||
Checked = _config.Branding.Enabled,
|
||||
CheckOnClick = true
|
||||
};
|
||||
brandingToggle.Click += (_, _) =>
|
||||
{
|
||||
_config.Branding.Enabled = brandingToggle.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
Log($"Branding {(brandingToggle.Checked ? "enabled" : "disabled")} — applies to next import.",
|
||||
CLR_ACCENT);
|
||||
// Toggling branding on/off swaps between the brand mark and the green circle.
|
||||
RefreshTrayIcon();
|
||||
};
|
||||
var brandingEdit = new ToolStripMenuItem("Edit branding…");
|
||||
brandingEdit.Click += (_, _) =>
|
||||
{
|
||||
_hideTimer?.Stop();
|
||||
using var f = new BrandingForm(_config);
|
||||
f.ShowDialog(this);
|
||||
// Reflect the (possibly changed) master switch back in the menu, and refresh the
|
||||
// tray icon so it picks up the user's new/changed mark immediately.
|
||||
brandingToggle.Checked = _config.Branding.Enabled;
|
||||
RefreshTrayIcon();
|
||||
};
|
||||
brandingItem.DropDownItems.AddRange(new ToolStripItem[] { brandingToggle, new ToolStripSeparator(), brandingEdit });
|
||||
|
||||
// Start-with-Windows toggle: copies the exe to %LocalAppData%\Programs\AutoIngest\ and
|
||||
// drops a .lnk in the user's Startup folder (the classic autorun mechanism — works on
|
||||
// Win11 unchanged). The shortcut's existence is the source of truth, so we refresh the
|
||||
// checkbox each time the menu opens in case it changed externally.
|
||||
var autostartItem = new ToolStripMenuItem("Start with Windows")
|
||||
{
|
||||
Checked = AutostartManager.IsEnabled,
|
||||
CheckOnClick = false // we control Checked explicitly after the action
|
||||
};
|
||||
autostartItem.Click += (_, _) =>
|
||||
{
|
||||
if (autostartItem.Checked)
|
||||
{
|
||||
// Currently enabled → turn off. Non-destructive: removes the shortcut, leaves
|
||||
// the installed exe in place so re-enabling doesn't re-copy.
|
||||
try
|
||||
{
|
||||
AutostartManager.Disable();
|
||||
autostartItem.Checked = false;
|
||||
Log("Removed from Startup. AutoIngest won't start at login.",
|
||||
CLR_ACCENT);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowToast();
|
||||
Log($"Couldn't disable autostart: {ex.Message}", CLR_RED);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Currently off → turn on: install + create shortcut. Takes effect next login.
|
||||
try
|
||||
{
|
||||
bool copied = AutostartManager.Enable();
|
||||
autostartItem.Checked = true;
|
||||
Log(copied
|
||||
? $"Installed to {AutostartManager.StableHome} and added to Startup. Starts at next login."
|
||||
: "Added to Startup (already installed). Starts at next login.",
|
||||
CLR_GREEN);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
ShowToast();
|
||||
Log($"Couldn't enable autostart: {ex.Message}", CLR_RED);
|
||||
MessageBox.Show(this,
|
||||
"Couldn't set up autostart:\n\n" + ex.Message +
|
||||
"\n\nThis usually means antivirus blocked the Startup shortcut, or the " +
|
||||
"destination folder isn't writable.",
|
||||
"Autostart", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
// Revert: read true state in case a partial change happened.
|
||||
autostartItem.Checked = AutostartManager.IsEnabled;
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
optionsItem.DropDownItems.AddRange(new ToolStripItem[] { autoOrientItem, stripExifItem, phoneDeleteItem, brandingItem, autostartItem });
|
||||
|
||||
// Refresh filesystem-derived checkboxes every time the menu opens, so the autostart
|
||||
// toggle stays accurate if it was changed externally (e.g. the user deleted the link).
|
||||
trayMenu.Opened += (_, _) =>
|
||||
{
|
||||
autostartItem.Checked = AutostartManager.IsEnabled;
|
||||
};
|
||||
|
||||
var aboutItem = new ToolStripMenuItem("About");
|
||||
aboutItem.Click += (_, _) =>
|
||||
MessageBox.Show(
|
||||
APP_NAME + "\n" +
|
||||
"Camera photo importer for ecommerce workflows\n\n" +
|
||||
"Author: Jeremy Anderson\n" +
|
||||
"Email: info@dcos.net\n" +
|
||||
"Web: https://dcos.net\n\n" +
|
||||
"C# / .NET 8 / Magick.NET\n\n" +
|
||||
"Licensed under the MIT License.\n" +
|
||||
"See https://opensource.org/license/MIT/",
|
||||
"About " + APP_NAME, MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
var exitItem = new ToolStripMenuItem("Exit");
|
||||
exitItem.Click += (_, _) => ExitApplication();
|
||||
|
||||
trayMenu.Items.AddRange(new ToolStripItem[]
|
||||
{
|
||||
showItem, folderItem, devicesItem, resetItem, optionsItem,
|
||||
new ToolStripSeparator(), aboutItem,
|
||||
new ToolStripSeparator(), exitItem
|
||||
});
|
||||
|
||||
// Tray icon is a micro version of the user's branding (logo or store initial) on an
|
||||
// auto-contrasting tile. Falls back to the green circle when branding is off. Held in
|
||||
// _trayBitmap for the NotifyIcon handle's lifetime; RefreshTrayIcon rebuilds it when
|
||||
// branding changes.
|
||||
_trayBitmap = TrayIconFactory.Build(_config.Branding, _config);
|
||||
|
||||
trayIcon = new NotifyIcon
|
||||
{
|
||||
Text = APP_NAME,
|
||||
Visible = true,
|
||||
ContextMenuStrip = trayMenu
|
||||
};
|
||||
SetTrayIcon(_trayBitmap);
|
||||
// Left-click toggles the toast so the user can peek at status on demand.
|
||||
trayIcon.MouseClick += (_, e) =>
|
||||
{
|
||||
if (e.Button == MouseButtons.Left)
|
||||
{
|
||||
if (Visible) Hide();
|
||||
else { ShowToast(); ArmAutoHide(); }
|
||||
}
|
||||
};
|
||||
trayIcon.DoubleClick += (_, _) => ShowToast();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Sets the tray icon from a bitmap, owning the GDI HICON lifetime. Bitmap.GetHicon()
|
||||
/// allocates an HICON the caller must free with DestroyIcon; Icon.FromHandle does NOT take
|
||||
/// ownership. We track the live handle and free the previous one on swap so repeated
|
||||
/// RefreshTrayIcon calls (branding changes) don't leak HICONs.
|
||||
/// </summary>
|
||||
IntPtr _currentIconHandle = IntPtr.Zero;
|
||||
void SetTrayIcon(Bitmap bmp)
|
||||
{
|
||||
if (trayIcon == null || bmp == null) return;
|
||||
IntPtr h = bmp.GetHicon();
|
||||
try { trayIcon.Icon = Icon.FromHandle(h); }
|
||||
catch { /* keep previous icon if the handle couldn't be made */ DestroyIcon(h); return; }
|
||||
// Free the previous handle now that the NotifyIcon has adopted the new one.
|
||||
if (_currentIconHandle != IntPtr.Zero) DestroyIcon(_currentIconHandle);
|
||||
_currentIconHandle = h;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the tray icon from the current branding and swaps it in. Call after anything
|
||||
/// that changes branding (welcome wizard, BrandingForm save) so the icon reflects the
|
||||
/// user's mark live. Safe to call repeatedly; frees the old HICON and disposes the old
|
||||
/// bitmap.
|
||||
/// </summary>
|
||||
void RefreshTrayIcon()
|
||||
{
|
||||
if (trayIcon == null) return;
|
||||
var fresh = TrayIconFactory.Build(_config.Branding, _config);
|
||||
var oldBitmap = _trayBitmap;
|
||||
_trayBitmap = fresh;
|
||||
SetTrayIcon(fresh);
|
||||
try { oldBitmap?.Dispose(); } catch { }
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region helpers
|
||||
static void OpenImportFolder()
|
||||
{
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
string picturesFolder = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
|
||||
string todayFolder = Path.Combine(picturesFolder, today);
|
||||
|
||||
string target = Directory.Exists(todayFolder) ? todayFolder
|
||||
: Directory.Exists(picturesFolder) ? picturesFolder
|
||||
: "";
|
||||
|
||||
if (target != "")
|
||||
Process.Start("explorer.exe", target);
|
||||
else
|
||||
MessageBox.Show("No Pictures folder found.", APP_NAME,
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
|
||||
void OpenSettings()
|
||||
{
|
||||
// Pause auto-hide so the toast doesn't fight the dialog for attention.
|
||||
_hideTimer?.Stop();
|
||||
using var form = new SettingsForm(
|
||||
_registry!,
|
||||
_config,
|
||||
() => _coordinator!.GetCurrentlyConnected(),
|
||||
() => _coordinator?.IsAccessTimeTrackingEnabled ?? false,
|
||||
() => _coordinator!.RunRetentionSweepNow());
|
||||
form.ShowDialog(this);
|
||||
}
|
||||
|
||||
void ExitApplication()
|
||||
{
|
||||
// Free the live HICON before the NotifyIcon tears down.
|
||||
if (_currentIconHandle != IntPtr.Zero) { DestroyIcon(_currentIconHandle); _currentIconHandle = IntPtr.Zero; }
|
||||
trayIcon.Visible = false;
|
||||
_coordinator?.Stop();
|
||||
Application.Exit();
|
||||
}
|
||||
|
||||
[System.Runtime.InteropServices.DllImport("user32.dll")]
|
||||
static extern bool DestroyIcon(IntPtr handle);
|
||||
#endregion
|
||||
|
||||
#region form events
|
||||
// Closing the toast (alt-f4 / clicking it off) should never quit the app —
|
||||
// it just hides. The app only exits via the tray's Exit item.
|
||||
void MainForm_FormClosing(object? sender, FormClosingEventArgs e)
|
||||
{
|
||||
if (e.CloseReason == CloseReason.UserClosing ||
|
||||
e.CloseReason == CloseReason.FormOwnerClosing)
|
||||
{
|
||||
e.Cancel = true;
|
||||
Hide();
|
||||
}
|
||||
// Any other close reason (taskmgr, shutdown, Application.Exit) is allowed.
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
#region dark menu renderer
|
||||
public class DarkMenuRenderer : ToolStripProfessionalRenderer
|
||||
{
|
||||
public DarkMenuRenderer() : base(new DarkColorTable()) { }
|
||||
}
|
||||
|
||||
public class DarkColorTable : ProfessionalColorTable
|
||||
{
|
||||
public override Color MenuStripGradientBegin => Color.FromArgb(42, 42, 42);
|
||||
public override Color MenuStripGradientEnd => Color.FromArgb(42, 42, 42);
|
||||
public override Color MenuItemSelected => Color.FromArgb(88, 166, 255);
|
||||
public override Color MenuItemBorder => Color.FromArgb(68, 68, 68);
|
||||
public override Color MenuBorder => Color.FromArgb(68, 68, 68);
|
||||
public override Color SeparatorDark => Color.FromArgb(68, 68, 68);
|
||||
public override Color SeparatorLight => Color.FromArgb(68, 68, 68);
|
||||
public override Color ImageMarginGradientBegin => Color.FromArgb(42, 42, 42);
|
||||
public override Color ImageMarginGradientMiddle => Color.FromArgb(42, 42, 42);
|
||||
public override Color ImageMarginGradientEnd => Color.FromArgb(42, 42, 42);
|
||||
public override Color ToolStripDropDownBackground => Color.FromArgb(35, 35, 35);
|
||||
public override Color ButtonSelectedHighlight => Color.FromArgb(88, 166, 255);
|
||||
public override Color ButtonSelectedBorder => Color.FromArgb(88, 166, 255);
|
||||
public override Color CheckBackground => Color.FromArgb(42, 42, 42);
|
||||
public override Color CheckSelectedBackground => Color.FromArgb(88, 166, 255);
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
Version 2.0
|
||||
Standard header required by the WinForms Designer to open MainForm visually.
|
||||
-->
|
||||
<resheader name="resmimetype">
|
||||
<value>text/microsoft-resx</value>
|
||||
</resheader>
|
||||
<resheader name="version">
|
||||
<value>2.0</value>
|
||||
</resheader>
|
||||
<resheader name="reader">
|
||||
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<resheader name="writer">
|
||||
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
|
||||
</resheader>
|
||||
<metadata name="trayMenu.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>17, 17</value>
|
||||
</metadata>
|
||||
<metadata name="trayIcon.TrayLocation" type="System.Drawing.Point, System.Drawing, Version=2.0.0.0, Culture=neutral, PublicKeyToken=b03f5f7f11d50a3a">
|
||||
<value>104, 17</value>
|
||||
</metadata>
|
||||
</root>
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
using System;
|
||||
using System.Threading;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
static class Program
|
||||
{
|
||||
static Mutex _mutex = null!;
|
||||
|
||||
[STAThread]
|
||||
static void Main()
|
||||
{
|
||||
bool owned;
|
||||
_mutex = new Mutex(true, "AutoIngest_SingleInstance", out owned);
|
||||
if (!owned)
|
||||
{
|
||||
MessageBox.Show(
|
||||
"AutoIngest is already running.",
|
||||
"Already Running",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
// Wire unhandled-exception handlers BEFORE Application.Run so a crash still leaves a
|
||||
// forensic trail in the file log. Without this, a fatal exception shows only the
|
||||
// generic "AutoIngest has stopped working" dialog and zero diagnostic record.
|
||||
AppDomain.CurrentDomain.UnhandledException += (_, e) =>
|
||||
{
|
||||
if (e.ExceptionObject is Exception ex)
|
||||
FileLogger.LogException("AppDomain unhandled exception", ex);
|
||||
};
|
||||
Application.ThreadException += (_, e) =>
|
||||
{
|
||||
FileLogger.LogException("UI thread unhandled exception", e.Exception);
|
||||
};
|
||||
Application.SetUnhandledExceptionMode(UnhandledExceptionMode.CatchException);
|
||||
|
||||
Application.EnableVisualStyles();
|
||||
Application.SetCompatibleTextRenderingDefault(false);
|
||||
Application.SetHighDpiMode(HighDpiMode.SystemAware);
|
||||
|
||||
// MainForm starts hidden — it lives in the tray and only pops up as a toast
|
||||
// when an SD card / USB import is in progress.
|
||||
Application.Run(new MainForm());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,507 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
using AutoIngest.Engine;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Settings dialog. Houses the device registry (trusted devices, register/remove, prompt
|
||||
/// toggle) and the retention section (auto-delete aged folders, age dropdown, access-time
|
||||
/// tracking status, and a "run sweep now" button). Themed to match the main toast.
|
||||
/// </summary>
|
||||
public class SettingsForm : Form
|
||||
{
|
||||
readonly DeviceRegistry _registry;
|
||||
readonly AppConfig _config;
|
||||
readonly Func<List<DeviceIdentity>> _getConnected;
|
||||
readonly Func<bool> _isAccessTrackingEnabled;
|
||||
readonly Func<(int recycled, long bytesReclaimed)> _runSweepNow;
|
||||
|
||||
ListView _devicesList = null!;
|
||||
ListView _connectedList = null!;
|
||||
CheckBox _promptCheck = null!;
|
||||
Button _removeBtn = null!;
|
||||
Button _registerBtn = null!;
|
||||
Button _closeBtn = null!;
|
||||
|
||||
// Retention controls.
|
||||
CheckBox _retentionCheck = null!;
|
||||
ComboBox _retentionCombo = null!;
|
||||
Label _accessStatus = null!;
|
||||
Button _enableAccessBtn = null!;
|
||||
Button _sweepNowBtn = null!;
|
||||
|
||||
// Fixed age presets offered in the dropdown, in that order. Days is what's persisted.
|
||||
static readonly (string Label, int Days)[] RETENTION_PRESETS =
|
||||
{
|
||||
("1 week", 7),
|
||||
("1 month", 30),
|
||||
("3 months", 90),
|
||||
("6 months", 180),
|
||||
("12 months", 365),
|
||||
("2 years", 730),
|
||||
("7 years", 2555),
|
||||
};
|
||||
|
||||
public SettingsForm(
|
||||
DeviceRegistry registry,
|
||||
AppConfig config,
|
||||
Func<List<DeviceIdentity>> getConnected,
|
||||
Func<bool> isAccessTrackingEnabled,
|
||||
Func<(int recycled, long bytesReclaimed)> runSweepNow)
|
||||
{
|
||||
_registry = registry;
|
||||
_config = config;
|
||||
_getConnected = getConnected;
|
||||
_isAccessTrackingEnabled = isAccessTrackingEnabled;
|
||||
_runSweepNow = runSweepNow;
|
||||
BuildUi();
|
||||
PopulateDevices();
|
||||
PopulateConnected();
|
||||
}
|
||||
|
||||
void BuildUi()
|
||||
{
|
||||
Text = "AutoIngest — Settings";
|
||||
StartPosition = FormStartPosition.CenterParent;
|
||||
ClientSize = new Size(560, 660);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
BackColor = Theme.BG;
|
||||
ForeColor = Theme.TEXT;
|
||||
Font = new Font("Segoe UI", 9F);
|
||||
|
||||
// --- Registered devices panel ----------------------------------------------
|
||||
var lblRegistered = new Label
|
||||
{
|
||||
Text = "Registered devices (import automatically)",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 24,
|
||||
Padding = new Padding(12, 8, 12, 4),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
Controls.Add(lblRegistered);
|
||||
|
||||
_devicesList = new ListView
|
||||
{
|
||||
View = View.Details,
|
||||
FullRowSelect = true,
|
||||
MultiSelect = false,
|
||||
BackColor = Theme.BG_DARK,
|
||||
ForeColor = Theme.TEXT,
|
||||
HeaderStyle = ColumnHeaderStyle.Nonclickable,
|
||||
Font = new Font("Consolas", 8.5F),
|
||||
Height = 160,
|
||||
Dock = DockStyle.Top
|
||||
};
|
||||
_devicesList.Columns.Add("Name", 180);
|
||||
_devicesList.Columns.Add("Kind", 80);
|
||||
_devicesList.Columns.Add("Added", 130);
|
||||
_devicesList.Columns.Add("Serial (last 6)", 120);
|
||||
Controls.Add(_devicesList);
|
||||
|
||||
// Order matters: Dock.Top fills bottom-up, so add children in reverse visual order.
|
||||
_devicesList.BringToFront();
|
||||
lblRegistered.SendToBack();
|
||||
|
||||
_removeBtn = new Button
|
||||
{
|
||||
Text = "Remove selected",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 28,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Enabled = false
|
||||
};
|
||||
_removeBtn.Click += (_, _) => RemoveSelected();
|
||||
Controls.Add(_removeBtn);
|
||||
_removeBtn.BringToFront();
|
||||
|
||||
// --- Connected devices panel ----------------------------------------------
|
||||
var lblConnected = new Label
|
||||
{
|
||||
Text = "Connected but not registered",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 24,
|
||||
Padding = new Padding(12, 8, 12, 4),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
Controls.Add(lblConnected);
|
||||
lblConnected.BringToFront();
|
||||
|
||||
_connectedList = new ListView
|
||||
{
|
||||
View = View.Details,
|
||||
FullRowSelect = true,
|
||||
MultiSelect = false,
|
||||
BackColor = Theme.BG_DARK,
|
||||
ForeColor = Theme.TEXT,
|
||||
HeaderStyle = ColumnHeaderStyle.Nonclickable,
|
||||
Font = new Font("Consolas", 8.5F),
|
||||
Height = 130,
|
||||
Dock = DockStyle.Top
|
||||
};
|
||||
_connectedList.Columns.Add("Name", 220);
|
||||
_connectedList.Columns.Add("Kind", 80);
|
||||
_connectedList.Columns.Add("Id", 240);
|
||||
Controls.Add(_connectedList);
|
||||
_connectedList.BringToFront();
|
||||
lblConnected.SendToBack();
|
||||
|
||||
_registerBtn = new Button
|
||||
{
|
||||
Text = "Register selected",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 28,
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Enabled = false
|
||||
};
|
||||
_registerBtn.Click += (_, _) => RegisterSelected();
|
||||
Controls.Add(_registerBtn);
|
||||
_registerBtn.BringToFront();
|
||||
|
||||
// --- Retention section ----------------------------------------------------
|
||||
BuildRetentionSection();
|
||||
|
||||
// --- Bottom row: prompt toggle + close ------------------------------------
|
||||
var bottomPanel = new Panel { Dock = DockStyle.Top, Height = 40, BackColor = Theme.BG };
|
||||
Controls.Add(bottomPanel);
|
||||
bottomPanel.BringToFront();
|
||||
|
||||
_promptCheck = new CheckBox
|
||||
{
|
||||
Text = "Ask before importing from unknown devices",
|
||||
Checked = _config.PromptOnUnknown,
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG,
|
||||
AutoSize = false,
|
||||
Location = new Point(12, 10),
|
||||
Size = new Size(320, 22)
|
||||
};
|
||||
_promptCheck.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_config.PromptOnUnknown = _promptCheck.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
};
|
||||
bottomPanel.Controls.Add(_promptCheck);
|
||||
|
||||
_closeBtn = new Button
|
||||
{
|
||||
Text = "Close",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(450, 8),
|
||||
Size = new Size(90, 26)
|
||||
};
|
||||
_closeBtn.Click += (_, _) => Close();
|
||||
bottomPanel.Controls.Add(_closeBtn);
|
||||
|
||||
_devicesList.SelectedIndexChanged += (_, _) =>
|
||||
_removeBtn.Enabled = _devicesList.SelectedItems.Count > 0;
|
||||
_connectedList.SelectedIndexChanged += (_, _) =>
|
||||
_registerBtn.Enabled = _connectedList.SelectedItems.Count > 0;
|
||||
|
||||
AcceptButton = _closeBtn;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the retention (auto-delete aged folders) section: enable checkbox, age dropdown,
|
||||
/// access-tracking status line, enable-access button, and a run-sweep-now button. Sits
|
||||
/// above the bottom prompt/close row. Saves config on every change.
|
||||
/// </summary>
|
||||
void BuildRetentionSection()
|
||||
{
|
||||
var panel = new Panel
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 150,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
Controls.Add(panel);
|
||||
|
||||
var lbl = new Label
|
||||
{
|
||||
Text = "Photo retention (reclaim disk space)",
|
||||
Location = new Point(12, 8),
|
||||
Size = new Size(540, 22),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
panel.Controls.Add(lbl);
|
||||
|
||||
_retentionCheck = new CheckBox
|
||||
{
|
||||
Text = "Auto-delete dated folders older than the selected age",
|
||||
Checked = _config.RetentionEnabled,
|
||||
Location = new Point(12, 32),
|
||||
Size = new Size(420, 22),
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG
|
||||
};
|
||||
|
||||
_retentionCombo = new ComboBox
|
||||
{
|
||||
DropDownStyle = ComboBoxStyle.DropDownList,
|
||||
Location = new Point(440, 30),
|
||||
Size = new Size(105, 22),
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Enabled = _config.RetentionEnabled
|
||||
};
|
||||
foreach (var preset in RETENTION_PRESETS)
|
||||
_retentionCombo.Items.Add(preset.Label);
|
||||
// Select the preset matching the persisted days, or "6 months" default.
|
||||
int sel = Array.FindIndex(RETENTION_PRESETS, p => p.Days == _config.RetentionDays);
|
||||
_retentionCombo.SelectedIndex = sel >= 0 ? sel : 3; // 3 = "6 months"
|
||||
|
||||
_retentionCheck.CheckedChanged += (_, _) =>
|
||||
{
|
||||
_config.RetentionEnabled = _retentionCheck.Checked;
|
||||
_retentionCombo.Enabled = _retentionCheck.Checked;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
};
|
||||
_retentionCombo.SelectedIndexChanged += (_, _) =>
|
||||
{
|
||||
int idx = _retentionCombo.SelectedIndex;
|
||||
if (idx >= 0 && idx < RETENTION_PRESETS.Length)
|
||||
{
|
||||
_config.RetentionDays = RETENTION_PRESETS[idx].Days;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
}
|
||||
};
|
||||
|
||||
panel.Controls.Add(_retentionCheck);
|
||||
panel.Controls.Add(_retentionCombo);
|
||||
|
||||
// Access-tracking status line. When access times are off (the Windows default),
|
||||
// retention falls back to import date — which still works, just doesn't reflect
|
||||
// "was this folder opened recently." We tell the user plainly.
|
||||
bool accessOn = _isAccessTrackingEnabled();
|
||||
_accessStatus = new Label
|
||||
{
|
||||
Location = new Point(12, 60),
|
||||
Size = new Size(420, 36),
|
||||
ForeColor = accessOn ? Theme.TEXT_DIM : Theme.RED,
|
||||
BackColor = Theme.BG,
|
||||
Text = accessOn
|
||||
? "Access tracking: ON — folders age out by last open."
|
||||
: "Access tracking: OFF — folders age out by import date.\n(Enable for true last-access tracking; needs admin + restart.)"
|
||||
};
|
||||
panel.Controls.Add(_accessStatus);
|
||||
|
||||
_enableAccessBtn = new Button
|
||||
{
|
||||
Text = accessOn ? "Access tracking enabled" : "Enable access tracking…",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(440, 58),
|
||||
Size = new Size(105, 28),
|
||||
Enabled = !accessOn
|
||||
};
|
||||
_enableAccessBtn.Click += (_, _) => EnableAccessTracking();
|
||||
panel.Controls.Add(_enableAccessBtn);
|
||||
|
||||
_sweepNowBtn = new Button
|
||||
{
|
||||
Text = "Run sweep now",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(12, 104),
|
||||
Size = new Size(140, 30)
|
||||
};
|
||||
_sweepNowBtn.Click += (_, _) => RunSweepNow();
|
||||
panel.Controls.Add(_sweepNowBtn);
|
||||
|
||||
var hint = new Label
|
||||
{
|
||||
Location = new Point(162, 108),
|
||||
Size = new Size(380, 24),
|
||||
ForeColor = Theme.TEXT_DIM,
|
||||
BackColor = Theme.BG,
|
||||
Text = "Eligible folders go to the Recycle Bin (recoverable). Today's folder is always exempt."
|
||||
};
|
||||
panel.Controls.Add(hint);
|
||||
|
||||
// Dock ordering: this panel must sit just above the bottom row. BringToFront puts it
|
||||
// at the top of the docked stack, but the bottom row is brought to front AFTER this
|
||||
// method returns, so it lands below this panel as intended.
|
||||
panel.BringToFront();
|
||||
}
|
||||
|
||||
void EnableAccessTracking()
|
||||
{
|
||||
if (MessageBox.Show(this,
|
||||
"Enable Windows access-time tracking?\n\n" +
|
||||
"This lets retention consider when folders were last opened, not just imported.\n\n" +
|
||||
"Windows will ask for administrator permission, and you'll need to restart your PC\n" +
|
||||
"for the change to fully take effect.",
|
||||
"Enable access tracking",
|
||||
MessageBoxButtons.OKCancel,
|
||||
MessageBoxIcon.Information) != DialogResult.OK) return;
|
||||
|
||||
// TryEnable is internally bounded at 30s (it WaitForExits the elevated fsutil). The
|
||||
// elevated process shows its own UAC prompt, so calling it directly on the UI thread is
|
||||
// correct and no worse than a pointless thread.Join(Infinite) that would freeze the UI
|
||||
// for the same duration.
|
||||
bool ok = AccessTimeTracker.TryEnable();
|
||||
|
||||
if (ok)
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Access-time tracking has been enabled.\n\nRestart your PC for it to take full effect.",
|
||||
"Enabled", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
_accessStatus.Text = "Access tracking: ON (after restart) — folders age out by last open.";
|
||||
_accessStatus.ForeColor = Theme.TEXT_DIM;
|
||||
_enableAccessBtn.Enabled = false;
|
||||
_enableAccessBtn.Text = "Enabled (restart)";
|
||||
}
|
||||
else
|
||||
{
|
||||
MessageBox.Show(this,
|
||||
"Couldn't enable access tracking — the admin prompt was cancelled or failed.\n" +
|
||||
"Retention will keep using import date, which still works fine.",
|
||||
"Not enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
void RunSweepNow()
|
||||
{
|
||||
// Confirm before deleting anything, even to the Recycle Bin. Show the threshold so
|
||||
// the user knows exactly what "now" means.
|
||||
string age = _retentionCombo.SelectedIndex >= 0
|
||||
? RETENTION_PRESETS[_retentionCombo.SelectedIndex].Label
|
||||
: $"{_config.RetentionDays} days";
|
||||
|
||||
if (!_config.RetentionEnabled)
|
||||
{
|
||||
if (MessageBox.Show(this,
|
||||
$"Retention is currently off.\n\nRun a one-time sweep now anyway, deleting folders older than {age}?",
|
||||
"Run sweep", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
|
||||
return;
|
||||
}
|
||||
else
|
||||
{
|
||||
if (MessageBox.Show(this,
|
||||
$"Run a retention sweep now?\n\nFolders older than {age} (by last activity) will be sent to the Recycle Bin.",
|
||||
"Run sweep", MessageBoxButtons.YesNo, MessageBoxIcon.Question) != DialogResult.Yes)
|
||||
return;
|
||||
}
|
||||
|
||||
_sweepNowBtn.Enabled = false;
|
||||
_sweepNowBtn.Text = "Sweeping…";
|
||||
try
|
||||
{
|
||||
var (recycled, reclaimed) = _runSweepNow();
|
||||
MessageBox.Show(this,
|
||||
recycled > 0
|
||||
? $"Sweep complete.\n\nRecycled {recycled} folder(s), reclaimed {AutoIngest.Engine.FileSize.Format(reclaimed)}.\nThey're in the Recycle Bin if you need them back."
|
||||
: "Sweep complete. Nothing was eligible for deletion.",
|
||||
"Sweep result", MessageBoxButtons.OK, MessageBoxIcon.Information);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
MessageBox.Show(this, $"Sweep failed: {ex.Message}", "Sweep error",
|
||||
MessageBoxButtons.OK, MessageBoxIcon.Error);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_sweepNowBtn.Enabled = true;
|
||||
_sweepNowBtn.Text = "Run sweep now";
|
||||
}
|
||||
}
|
||||
|
||||
void PopulateDevices()
|
||||
{
|
||||
_devicesList.BeginUpdate();
|
||||
_devicesList.Items.Clear();
|
||||
foreach (var d in _registry.All)
|
||||
{
|
||||
var item = new ListViewItem(d.Name);
|
||||
item.SubItems.Add(d.Kind);
|
||||
item.SubItems.Add(d.AddedUtc.ToLocalTime().ToString("yyyy-MM-dd HH:mm"));
|
||||
item.SubItems.Add(TruncateTail(d.Id, 6));
|
||||
item.Tag = d.Id;
|
||||
item.ForeColor = Theme.TEXT;
|
||||
_devicesList.Items.Add(item);
|
||||
}
|
||||
_devicesList.EndUpdate();
|
||||
}
|
||||
|
||||
void PopulateConnected()
|
||||
{
|
||||
_connectedList.BeginUpdate();
|
||||
_connectedList.Items.Clear();
|
||||
try
|
||||
{
|
||||
var connected = _getConnected();
|
||||
var registered = new HashSet<string>(
|
||||
_registry.All.Select(d => d.Id),
|
||||
StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
foreach (var dev in connected)
|
||||
{
|
||||
if (registered.Contains(dev.Id)) continue;
|
||||
var item = new ListViewItem(dev.DisplayName);
|
||||
item.SubItems.Add(dev.Kind);
|
||||
item.SubItems.Add(dev.Id);
|
||||
item.Tag = dev;
|
||||
item.ForeColor = dev.Kind.Contains("weak", StringComparison.OrdinalIgnoreCase)
|
||||
? Theme.TEXT_DIM : Theme.TEXT;
|
||||
_connectedList.Items.Add(item);
|
||||
}
|
||||
}
|
||||
catch { /* best-effort refresh */ }
|
||||
_connectedList.EndUpdate();
|
||||
}
|
||||
|
||||
void RemoveSelected()
|
||||
{
|
||||
if (_devicesList.SelectedItems.Count == 0) return;
|
||||
var item = _devicesList.SelectedItems[0];
|
||||
string? id = item.Tag as string;
|
||||
if (id == null) return;
|
||||
|
||||
// Confirm — removing a device means it will prompt next time it's plugged in.
|
||||
var dev = _registry.Find(id);
|
||||
if (dev == null) return;
|
||||
if (MessageBox.Show(this,
|
||||
$"Remove '{dev.Name}' from registered devices?\n\n" +
|
||||
"It will no longer import automatically.",
|
||||
"Remove device", MessageBoxButtons.YesNo, MessageBoxIcon.Question)
|
||||
!= DialogResult.Yes) return;
|
||||
|
||||
_registry.Remove(id);
|
||||
_registry.Save();
|
||||
PopulateDevices();
|
||||
}
|
||||
|
||||
void RegisterSelected()
|
||||
{
|
||||
if (_connectedList.SelectedItems.Count == 0) return;
|
||||
var item = _connectedList.SelectedItems[0];
|
||||
if (item.Tag is not DeviceIdentity dev) return;
|
||||
|
||||
_registry.Register(dev.Id, dev.DisplayName, dev.Kind);
|
||||
_registry.Save();
|
||||
PopulateDevices();
|
||||
PopulateConnected();
|
||||
}
|
||||
|
||||
static string TruncateTail(string s, int n) =>
|
||||
string.IsNullOrEmpty(s) || s.Length <= n ? s : s.Substring(s.Length - n);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
using System.Drawing;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Shared dark-theme palette. Extracted from MainForm so the settings dialog and any future
|
||||
/// UI reuse the exact same colors. Hex values match the existing MainForm constants.
|
||||
/// </summary>
|
||||
public static class Theme
|
||||
{
|
||||
public static readonly Color BG = Color.FromArgb(30, 30, 30);
|
||||
public static readonly Color BG2 = Color.FromArgb(42, 42, 42);
|
||||
public static readonly Color BG_DARK = Color.FromArgb(20, 20, 20);
|
||||
public static readonly Color TEXT = Color.FromArgb(224, 224, 224);
|
||||
public static readonly Color TEXT_DIM = Color.FromArgb(153, 153, 153);
|
||||
public static readonly Color ACCENT = Color.FromArgb(88, 166, 255);
|
||||
public static readonly Color GREEN = Color.FromArgb(63, 185, 80);
|
||||
public static readonly Color RED = Color.FromArgb(248, 81, 73);
|
||||
public static readonly Color BORDER = Color.FromArgb(68, 68, 68);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,268 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Drawing;
|
||||
using System.Drawing.Drawing2D;
|
||||
using System.Drawing.Imaging;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using AutoIngest.Core;
|
||||
using ImageMagick;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds the tray icon as a micro version of the user's branding — a rounded-square tile
|
||||
/// with the mark (logo or store initial) on an auto-contrasting background. The "intelligent
|
||||
/// color matching": we sample the mark's perceived luminance and pick the opposite background
|
||||
/// (light mark → dark bg, dark mark → light bg) so the icon reads on any taskbar color.
|
||||
///
|
||||
/// Pipeline: render the mark at 64×64 (super-sampled for crispness), composite onto the tile
|
||||
/// background, then downsample to 16×16 with high-quality interpolation. Logo scaling uses
|
||||
/// Magick.NET (already a dependency, handles PNG alpha); tile/initial/downsample use GDI+
|
||||
/// (already in use for the tray, simpler for rounded-rect + hicon).
|
||||
///
|
||||
/// Fallbacks: corrupt/missing logo → initial → lime-green circle (current default). Branding
|
||||
/// off entirely → green circle. Never throws; never leaves the tray empty.
|
||||
/// </summary>
|
||||
public static class TrayIconFactory
|
||||
{
|
||||
// Strong backgrounds (not mid-tones) so contrast is guaranteed on either taskbar theme.
|
||||
static readonly Color DarkBg = Color.FromArgb(31, 31, 31); // ~#1f1f1f
|
||||
static readonly Color LightBg = Color.FromArgb(240, 240, 240); // ~#f0f0f0
|
||||
// The initial is drawn dark by default; the contrast step pairs it with a light bg.
|
||||
static readonly Color DarkMark = Color.FromArgb(30, 30, 30);
|
||||
|
||||
const int HiRes = 64; // super-sample at 4x for crisp downscale to 16
|
||||
const int FinalSize = 16;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the tray icon bitmap. Returns the green-circle fallback when branding is off or
|
||||
/// nothing usable is configured, so there's never an empty/garbled icon.
|
||||
/// </summary>
|
||||
public static Bitmap Build(BrandingConfig? branding, AppConfig config)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Branding disabled or nothing to show → current default.
|
||||
if (branding == null || !branding.Enabled) return GreenCircle();
|
||||
|
||||
// Try a logo first, then the store initial, then the green fallback.
|
||||
Bitmap? mark = null;
|
||||
if (branding.LogoWatermarkEnabled && File.Exists(branding.LogoPath))
|
||||
mark = RenderLogo(branding.LogoPath);
|
||||
|
||||
if (mark == null && HasInitial(branding, out string initial))
|
||||
mark = RenderInitial(initial);
|
||||
|
||||
if (mark == null) return GreenCircle();
|
||||
|
||||
using (mark)
|
||||
{
|
||||
double luminance = MedianLuminance(mark);
|
||||
Color bg = luminance >= 0.5 ? DarkBg : LightBg;
|
||||
return ComposeTile(mark, bg);
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Any surprise (GDI+ failure, OOM, etc.) → safe default. Never throw out of Build.
|
||||
return GreenCircle();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Renders the logo onto a 64×32 transparent canvas, fit-contained with padding.</summary>
|
||||
static Bitmap? RenderLogo(string logoPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var logo = new MagickImage(logoPath);
|
||||
|
||||
int pad = HiRes / 10; // ~10% padding so the mark doesn't kiss the tile edge
|
||||
int maxW = HiRes - pad * 2;
|
||||
int maxH = HiRes - pad * 2;
|
||||
|
||||
// Scale to fit while preserving aspect ratio.
|
||||
double scale = Math.Min((double)maxW / logo.Width, (double)maxH / logo.Height);
|
||||
if (scale > 1.0) scale = 1.0; // don't upscale tiny logos — they'd blur
|
||||
int newW = Math.Max(1, (int)(logo.Width * scale));
|
||||
int newH = Math.Max(1, (int)(logo.Height * scale));
|
||||
logo.Scale((uint)newW, (uint)newH);
|
||||
|
||||
// Composite centered onto a transparent HiRes canvas.
|
||||
using var canvas = new MagickImage(MagickColors.None, (uint)HiRes, (uint)HiRes);
|
||||
int x = (HiRes - newW) / 2;
|
||||
int y = (HiRes - newH) / 2;
|
||||
canvas.Composite(logo, x, y, CompositeOperator.Over);
|
||||
|
||||
// Export to a GDI+ bitmap with alpha intact.
|
||||
using var ms = new MemoryStream();
|
||||
canvas.Write(ms, MagickFormat.Png);
|
||||
ms.Position = 0;
|
||||
return new Bitmap(ms);
|
||||
}
|
||||
catch (MagickException)
|
||||
{
|
||||
return null; // corrupt/unreadable logo → caller falls back to initial
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Draws a single capital letter, bold, sized to ~70% of the 64px canvas.</summary>
|
||||
static Bitmap RenderInitial(string initial)
|
||||
{
|
||||
var bmp = new Bitmap(HiRes, HiRes, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(bmp))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.TextRenderingHint = System.Drawing.Text.TextRenderingHint.AntiAlias;
|
||||
g.Clear(Color.Transparent);
|
||||
|
||||
// Size the font so the glyph fills ~70% of the canvas height. Step down until it fits.
|
||||
float fontSize = HiRes;
|
||||
Font font;
|
||||
SizeF measured;
|
||||
do
|
||||
{
|
||||
fontSize -= 2;
|
||||
font = new Font("Segoe UI", fontSize, FontStyle.Bold, GraphicsUnit.Pixel);
|
||||
measured = g.MeasureString(initial, font);
|
||||
} while ((measured.Height > HiRes * 0.78 || measured.Width > HiRes * 0.85) && fontSize > 8);
|
||||
|
||||
using (font)
|
||||
{
|
||||
// Drawn dark; the contrast step will pair it with a light bg. Centered.
|
||||
var fmt = new StringFormat
|
||||
{
|
||||
Alignment = StringAlignment.Center,
|
||||
LineAlignment = StringAlignment.Center
|
||||
};
|
||||
var rect = new RectangleF(0, 0, HiRes, HiRes);
|
||||
using var brush = new SolidBrush(DarkMark);
|
||||
g.DrawString(initial, font, brush, rect, fmt);
|
||||
}
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Composites the mark onto a rounded-square tile of the chosen background, then downsamples
|
||||
/// to the final 16×16 with high-quality interpolation.
|
||||
/// </summary>
|
||||
static Bitmap ComposeTile(Bitmap mark, Color bg)
|
||||
{
|
||||
var hi = new Bitmap(HiRes, HiRes, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(hi))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
|
||||
// Rounded-square background. Radius ~22% gives the Win11 "squircle-ish" badge feel.
|
||||
using var path = RoundedRect(0, 0, HiRes, HiRes, HiRes * 0.22f);
|
||||
using var bgBrush = new SolidBrush(bg);
|
||||
g.FillPath(bgBrush, path);
|
||||
|
||||
// Draw the mark on top. It has its own alpha (logo) or is opaque (initial), and
|
||||
// already sits centered in its own 64px canvas, so a 1:1 copy lands it correctly.
|
||||
g.DrawImage(mark, new Rectangle(0, 0, HiRes, HiRes));
|
||||
}
|
||||
|
||||
// Downsample to 16×16. This is where the super-sampling pays off.
|
||||
var final = new Bitmap(FinalSize, FinalSize, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(final))
|
||||
{
|
||||
g.InterpolationMode = InterpolationMode.HighQualityBicubic;
|
||||
g.PixelOffsetMode = PixelOffsetMode.HighQuality;
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.DrawImage(hi, new Rectangle(0, 0, FinalSize, FinalSize));
|
||||
}
|
||||
hi.Dispose();
|
||||
return final;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Median luminance of the mark's non-transparent pixels. Median (not average) so a
|
||||
/// black-and-white logo doesn't average to mid-gray and produce a wrong contrast choice.
|
||||
/// Returns 0.0 (dark) for a fully-transparent mark.
|
||||
/// </summary>
|
||||
static double MedianLuminance(Bitmap bmp)
|
||||
{
|
||||
var lums = new List<double>();
|
||||
var rect = new Rectangle(0, 0, bmp.Width, bmp.Height);
|
||||
var data = bmp.LockBits(rect, ImageLockMode.ReadOnly, PixelFormat.Format32bppArgb);
|
||||
try
|
||||
{
|
||||
int bytes = data.Stride * data.Height;
|
||||
var buf = new byte[bytes];
|
||||
System.Runtime.InteropServices.Marshal.Copy(data.Scan0, buf, 0, bytes);
|
||||
for (int y = 0; y < data.Height; y++)
|
||||
{
|
||||
int row = y * data.Stride;
|
||||
for (int x = 0; x < data.Width; x++)
|
||||
{
|
||||
int i = row + x * 4;
|
||||
byte a = buf[i + 3];
|
||||
if (a < 16) continue; // treat near-transparent as no mark
|
||||
byte b = buf[i + 0], g = buf[i + 1], r = buf[i + 2]; // 32bppArgb is BGRA
|
||||
double lum = (0.299 * r + 0.587 * g + 0.114 * b) / 255.0;
|
||||
lums.Add(lum);
|
||||
}
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
bmp.UnlockBits(data);
|
||||
}
|
||||
if (lums.Count == 0) return 0.0;
|
||||
lums.Sort();
|
||||
return lums[lums.Count / 2];
|
||||
}
|
||||
|
||||
static System.Drawing.Drawing2D.GraphicsPath RoundedRect(float x, float y, float w, float h, float r)
|
||||
{
|
||||
var path = new System.Drawing.Drawing2D.GraphicsPath();
|
||||
float d = r * 2;
|
||||
path.AddArc(x, y, d, d, 180, 90);
|
||||
path.AddArc(x + w - d, y, d, d, 270, 90);
|
||||
path.AddArc(x + w - d, y + h - d, d, d, 0, 90);
|
||||
path.AddArc(x, y + h - d, d, d, 90, 90);
|
||||
path.CloseFigure();
|
||||
return path;
|
||||
}
|
||||
|
||||
static bool HasInitial(BrandingConfig b, out string initial)
|
||||
{
|
||||
// Prefer store name, then handle; first non-space character.
|
||||
foreach (var source in new[] { b.StoreName, b.SellerHandle })
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(source))
|
||||
{
|
||||
char c = source.Trim()[0];
|
||||
if (char.IsLetterOrDigit(c))
|
||||
{
|
||||
initial = char.ToUpperInvariant(c).ToString();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
initial = "";
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>The original lime-green circle — the no-branding fallback.</summary>
|
||||
static Bitmap GreenCircle()
|
||||
{
|
||||
var bmp = new Bitmap(FinalSize, FinalSize, PixelFormat.Format32bppArgb);
|
||||
using (var g = Graphics.FromImage(bmp))
|
||||
{
|
||||
g.SmoothingMode = SmoothingMode.AntiAlias;
|
||||
g.FillEllipse(Brushes.LimeGreen, 1, 1, 14, 14);
|
||||
g.DrawEllipse(Pens.DarkGreen, 1, 1, 14, 14);
|
||||
}
|
||||
return bmp;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
using System;
|
||||
using System.Drawing;
|
||||
using System.Windows.Forms;
|
||||
using AutoIngest.Core;
|
||||
|
||||
namespace AutoIngest.App
|
||||
{
|
||||
/// <summary>
|
||||
/// First-run welcome dialog, shown only on a brand-new install (no config file at load time).
|
||||
/// Two pages: a welcome screen (Skip / Customize) and the branding editor. Finishing either
|
||||
/// path sets <c>SetupCompleted = true</c> and persists config so the wizard never re-appears.
|
||||
/// Skipping leaves branding off (defaults) — the user can always opt in later via the tray.
|
||||
/// </summary>
|
||||
public class WelcomeForm : Form
|
||||
{
|
||||
readonly AppConfig _config;
|
||||
BrandingEditorPanel? _editor;
|
||||
|
||||
public WelcomeForm(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
BuildUi();
|
||||
ShowWelcomePage();
|
||||
}
|
||||
|
||||
void BuildUi()
|
||||
{
|
||||
Text = "Welcome to AutoIngest";
|
||||
StartPosition = FormStartPosition.CenterScreen;
|
||||
ClientSize = new Size(760, 640);
|
||||
FormBorderStyle = FormBorderStyle.FixedDialog;
|
||||
MaximizeBox = false;
|
||||
MinimizeBox = false;
|
||||
BackColor = Theme.BG;
|
||||
ForeColor = Theme.TEXT;
|
||||
Font = new Font("Segoe UI", 9F);
|
||||
}
|
||||
|
||||
void ShowWelcomePage()
|
||||
{
|
||||
Controls.Clear();
|
||||
var host = new Panel { Dock = DockStyle.Fill, BackColor = Theme.BG };
|
||||
Controls.Add(host);
|
||||
|
||||
var title = new Label
|
||||
{
|
||||
Text = "Welcome to AutoIngest",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 40,
|
||||
Font = new Font("Segoe UI", 16F, FontStyle.Bold),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG,
|
||||
TextAlign = ContentAlignment.MiddleLeft,
|
||||
Padding = new Padding(24, 0, 24, 0)
|
||||
};
|
||||
host.Controls.Add(title);
|
||||
|
||||
var body = new Label
|
||||
{
|
||||
Dock = DockStyle.Top,
|
||||
Height = 180,
|
||||
ForeColor = Theme.TEXT,
|
||||
BackColor = Theme.BG,
|
||||
Padding = new Padding(24, 8, 24, 8),
|
||||
Text =
|
||||
"AutoIngest moves and converts photos off your camera or phone into a dated\r\n" +
|
||||
"folder, ready to attach to your listings.\r\n\r\n" +
|
||||
"Optionally, you can brand every converted photo with your store name, a logo\r\n" +
|
||||
"watermark, and a copyright notice — great for a consistent eBay storefront.\r\n\r\n" +
|
||||
"Want to set that up now? You can skip and enable it any time from the tray\r\n" +
|
||||
"menu (Options ▸ Branding).",
|
||||
TextAlign = ContentAlignment.TopLeft
|
||||
};
|
||||
host.Controls.Add(body);
|
||||
|
||||
var buttonRow = new Panel { Dock = DockStyle.Bottom, Height = 48, BackColor = Theme.BG };
|
||||
host.Controls.Add(buttonRow);
|
||||
|
||||
var customize = new Button
|
||||
{
|
||||
Text = "Customize branding",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.ACCENT,
|
||||
ForeColor = Color.White,
|
||||
Location = new Point(ClientSize.Width - 350, 10),
|
||||
Size = new Size(160, 30)
|
||||
};
|
||||
customize.Click += (_, _) => ShowBrandingPage();
|
||||
|
||||
var skip = new Button
|
||||
{
|
||||
Text = "Skip for now",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(ClientSize.Width - 175, 10),
|
||||
Size = new Size(160, 30)
|
||||
};
|
||||
skip.Click += (_, _) => Finish();
|
||||
|
||||
buttonRow.Controls.Add(customize);
|
||||
buttonRow.Controls.Add(skip);
|
||||
AcceptButton = customize;
|
||||
}
|
||||
|
||||
void ShowBrandingPage()
|
||||
{
|
||||
Controls.Clear();
|
||||
|
||||
var header = new Label
|
||||
{
|
||||
Text = " Set up your branding",
|
||||
Dock = DockStyle.Top,
|
||||
Height = 32,
|
||||
Font = new Font("Segoe UI", 11F, FontStyle.Bold),
|
||||
ForeColor = Theme.ACCENT,
|
||||
BackColor = Theme.BG2,
|
||||
TextAlign = ContentAlignment.MiddleLeft
|
||||
};
|
||||
Controls.Add(header);
|
||||
|
||||
_editor = new BrandingEditorPanel(_config);
|
||||
Controls.Add(_editor);
|
||||
|
||||
var bottom = new Panel { Dock = DockStyle.Bottom, Height = 44, BackColor = Theme.BG };
|
||||
Controls.Add(bottom);
|
||||
|
||||
var back = new Button
|
||||
{
|
||||
Text = "Back",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(12, 8),
|
||||
Size = new Size(90, 28)
|
||||
};
|
||||
back.Click += (_, _) => { _editor = null; ShowWelcomePage(); };
|
||||
|
||||
var finish = new Button
|
||||
{
|
||||
Text = "Finish",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.ACCENT,
|
||||
ForeColor = Color.White,
|
||||
Location = new Point(ClientSize.Width - 200, 8),
|
||||
Size = new Size(85, 28)
|
||||
};
|
||||
finish.Click += (_, _) =>
|
||||
{
|
||||
_editor?.Save();
|
||||
Finish();
|
||||
};
|
||||
|
||||
var skip = new Button
|
||||
{
|
||||
Text = "Skip",
|
||||
FlatStyle = FlatStyle.Flat,
|
||||
BackColor = Theme.BG2,
|
||||
ForeColor = Theme.TEXT,
|
||||
Location = new Point(ClientSize.Width - 105, 8),
|
||||
Size = new Size(85, 28)
|
||||
};
|
||||
skip.Click += (_, _) => Finish();
|
||||
|
||||
bottom.Controls.Add(back);
|
||||
bottom.Controls.Add(finish);
|
||||
bottom.Controls.Add(skip);
|
||||
AcceptButton = finish;
|
||||
|
||||
// Render an initial preview once the page is laid out.
|
||||
BeginInvoke((Action)(() => _editor?.RenderPreview()));
|
||||
}
|
||||
|
||||
/// <summary>Mark setup complete and close. Always persists so the wizard never re-shows.</summary>
|
||||
void Finish()
|
||||
{
|
||||
_config.SetupCompleted = true;
|
||||
ConfigManager.SaveConfig(_config);
|
||||
DialogResult = DialogResult.OK;
|
||||
Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
|
||||
<assemblyIdentity version="1.0.0.0" name="AutoIngest"/>
|
||||
<compatibility xmlns="urn:schemas-microsoft-com:compatibility.v1">
|
||||
<application>
|
||||
<supportedOS Id="{8e0f7a12-bfb3-4fe8-b9a5-48fd50a15a9a}" />
|
||||
</application>
|
||||
</compatibility>
|
||||
</assembly>
|
||||
|
|
@ -0,0 +1,26 @@
|
|||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net8.0-windows</TargetFramework>
|
||||
<UseWindowsForms>true</UseWindowsForms>
|
||||
<Nullable>enable</Nullable>
|
||||
<AssemblyName>AutoIngest</AssemblyName>
|
||||
<RootNamespace>AutoIngest</RootNamespace>
|
||||
<Version>3.0.0</Version>
|
||||
<AssemblyTitle>AutoIngest</AssemblyTitle>
|
||||
<Product>AutoIngest</Product>
|
||||
<Description>Camera photo importer for ecommerce workflows</Description>
|
||||
<Company>dcos.net</Company>
|
||||
<Authors>Jeremy Anderson</Authors>
|
||||
<Copyright>Copyright © Jeremy Anderson</Copyright>
|
||||
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||
<PackageProjectUrl>https://dcos.net</PackageProjectUrl>
|
||||
<ApplicationManifest>App\app.manifest</ApplicationManifest>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Magick.NET-Q16-AnyCPU" Version="14.16.0" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
|
@ -0,0 +1,227 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text.Json;
|
||||
|
||||
namespace AutoIngest.Core
|
||||
{
|
||||
public class AppConfig
|
||||
{
|
||||
public int JpgQuality { get; set; } = 92;
|
||||
public bool AutoImport { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Devices the user has explicitly approved for import. Empty by default —
|
||||
/// no device imports until it has been registered here (or the user picks
|
||||
/// "Import once" for a session). This is the opt-in registry: a friend's
|
||||
/// USB stick or phone never matches and is left alone.
|
||||
/// </summary>
|
||||
public List<RegisteredDevice> Devices { get; set; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// When true (default), an unregistered device raises a prompt the first time
|
||||
/// it's seen each session. When false, unregistered devices are silently skipped.
|
||||
/// </summary>
|
||||
public bool PromptOnUnknown { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, dated import folders older than <see cref="RetentionDays"/> (by last
|
||||
/// access, or last write if access tracking is off) are sent to the Recycle Bin on a
|
||||
/// daily sweep. Default false — retention is strictly opt-in.
|
||||
/// </summary>
|
||||
public bool RetentionEnabled { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// Age threshold in days above which a dated folder is eligible for recycling. Default
|
||||
/// ~6 months (180). The UI offers fixed presets (1 week through 7 years) that map to this.
|
||||
/// </summary>
|
||||
public int RetentionDays { get; set; } = 180;
|
||||
|
||||
/// <summary>
|
||||
/// When true, photos imported from a phone (MTP) are deleted from the phone after the
|
||||
/// local copy is verified — mirroring the "move, not copy" behavior SD/USB have always had.
|
||||
/// When false (default), phones are copy-and-leave: the user curates phone storage
|
||||
/// themselves. This affects phones ONLY; SD/USB always moved/converted as before.
|
||||
/// </summary>
|
||||
public bool DeleteFromPhoneAfterImport { get; set; } = false;
|
||||
|
||||
/// <summary>
|
||||
/// When true, converted JPGs are auto-oriented using their EXIF orientation tag before
|
||||
/// write. Phones and some cameras set the tag without rotating the pixels, so the image
|
||||
/// appears sideways in browsers that don't honor the tag. Default on — pure win.
|
||||
/// </summary>
|
||||
public bool AutoOrient { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// When true, EXIF metadata (including GPS coordinates) is stripped from converted JPGs.
|
||||
/// Phone photos can leak the seller's location into listing images. Default on.
|
||||
/// Applies to converted files; JPGs that are moved directly are untouched (see remarks
|
||||
/// in ImageConverter for why).
|
||||
/// </summary>
|
||||
public bool StripExif { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Whether the first-run welcome wizard has been completed. Default true so existing
|
||||
/// users (whose config predates this flag) are never prompted. A brand-new install has
|
||||
/// no config file at all, which is what MainForm actually keys the wizard on.
|
||||
/// </summary>
|
||||
public bool SetupCompleted { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Branding settings for converted photos: visible text/logo watermarks and optional
|
||||
/// EXIF copyright fields. Off by default; the user opts in via the welcome wizard or
|
||||
/// tray Options ▸ Branding. See <see cref="BrandingConfig"/> for details.
|
||||
/// </summary>
|
||||
public BrandingConfig Branding { get; set; } = new();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Corner/edge anchors for overlay positioning, expressed generically so the same enum
|
||||
/// serves both the text watermark and the logo watermark.
|
||||
/// </summary>
|
||||
public enum WatermarkPosition
|
||||
{
|
||||
BottomRight,
|
||||
BottomLeft,
|
||||
TopRight,
|
||||
TopLeft,
|
||||
Center
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Branding applied to converted photos. Everything is off by default — the user opts in
|
||||
/// via the welcome wizard or tray Options ▸ Branding ▸ Enabled. Three independent features:
|
||||
/// a visible text watermark (store name/URL/handle), a visible logo watermark (PNG), and
|
||||
/// EXIF copyright fields. Watermarks are burned into pixels; EXIF copyright is written
|
||||
/// AFTER metadata stripping so the seller's info survives while camera/GPS tags are wiped.
|
||||
/// </summary>
|
||||
public class BrandingConfig
|
||||
{
|
||||
/// <summary>Master switch. When false, none of the branding features run.</summary>
|
||||
public bool Enabled { get; set; } = false;
|
||||
|
||||
// --- Store identity (drives the text watermark content) ---
|
||||
public string StoreName { get; set; } = "";
|
||||
public string StoreUrl { get; set; } = "";
|
||||
public string SellerHandle { get; set; } = "";
|
||||
|
||||
// --- Text watermark ---
|
||||
public bool TextWatermarkEnabled { get; set; } = false;
|
||||
public WatermarkPosition TextPosition { get; set; } = WatermarkPosition.BottomRight;
|
||||
/// <summary>0–100. How opaque the text is.</summary>
|
||||
public int TextOpacity { get; set; } = 60;
|
||||
|
||||
// --- Logo watermark ---
|
||||
public bool LogoWatermarkEnabled { get; set; } = false;
|
||||
public string LogoPath { get; set; } = "";
|
||||
public WatermarkPosition LogoPosition { get; set; } = WatermarkPosition.BottomRight;
|
||||
/// <summary>0–100. How opaque the logo is.</summary>
|
||||
public int LogoOpacity { get; set; } = 50;
|
||||
/// <summary>Logo scale as a percentage of the photo's width. 0 = use the logo's native size.</summary>
|
||||
public int LogoScalePercent { get; set; } = 20;
|
||||
|
||||
// --- EXIF copyright (written after Strip, so only this survives) ---
|
||||
public bool ExifCopyrightEnabled { get; set; } = false;
|
||||
public string ExifArtist { get; set; } = "";
|
||||
public string ExifCopyright { get; set; } = "";
|
||||
public string ExifDescription { get; set; } = "";
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A device the user has trusted for import. <see cref="Id"/> is a stable per-device
|
||||
/// fingerprint (volume serial for SD/USB, PTP serial for phones) so the same physical
|
||||
/// device is recognized across reboots and drive-letter changes. <see cref="Name"/> and
|
||||
/// <see cref="Kind"/> are human-facing labels.
|
||||
/// </summary>
|
||||
public class RegisteredDevice
|
||||
{
|
||||
/// <summary>Stable device fingerprint. See <see cref="DeviceIdentity.Id"/> for format.</summary>
|
||||
public string Id { get; set; } = "";
|
||||
|
||||
/// <summary>Friendly name as shown in the UI (volume label or phone name).</summary>
|
||||
public string Name { get; set; } = "";
|
||||
|
||||
/// <summary>"SD", "USB", "USB-weak", or "Phone".</summary>
|
||||
public string Kind { get; set; } = "";
|
||||
|
||||
/// <summary>When the device was added to the registry (UTC).</summary>
|
||||
public DateTime AddedUtc { get; set; } = DateTime.UtcNow;
|
||||
}
|
||||
|
||||
public static class ConfigManager
|
||||
{
|
||||
static readonly string CONFIG_PATH = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.UserProfile),
|
||||
".autoingest_config.json");
|
||||
|
||||
// Backup of the previous config, written atomically by every SaveConfig via File.Replace.
|
||||
// LoadConfig falls back to this when the main file is corrupt (partial write from a crash,
|
||||
// AV quarantine, disk error) so a corruption event no longer silently wipes the device
|
||||
// registry and all settings back to defaults.
|
||||
static readonly string CONFIG_BAK_PATH = CONFIG_PATH + ".bak";
|
||||
|
||||
static readonly JsonSerializerOptions JSON_OPTS = new() { WriteIndented = true };
|
||||
|
||||
/// <summary>
|
||||
/// True if a config file already exists on disk. Used to detect a brand-new install
|
||||
/// (first launch ever) so the welcome wizard can run exactly once. Existing users
|
||||
/// upgrading will have a config file and won't be prompted.
|
||||
/// </summary>
|
||||
public static bool ConfigExists => File.Exists(CONFIG_PATH);
|
||||
|
||||
public static AppConfig LoadConfig()
|
||||
{
|
||||
// Try the main file first; on a JsonException (corrupt/partial), fall back to the
|
||||
// .bak before giving up and returning defaults. This closes the silent-wipe path.
|
||||
if (TryLoad(CONFIG_PATH, out var config)) return config;
|
||||
if (TryLoad(CONFIG_BAK_PATH, out config)) return config;
|
||||
return new AppConfig();
|
||||
}
|
||||
|
||||
static bool TryLoad(string path, out AppConfig config)
|
||||
{
|
||||
config = new AppConfig();
|
||||
try
|
||||
{
|
||||
if (File.Exists(path))
|
||||
{
|
||||
config = JsonSerializer.Deserialize<AppConfig>(File.ReadAllText(path)) ?? new AppConfig();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
catch (JsonException) { }
|
||||
return false;
|
||||
}
|
||||
|
||||
public static void SaveConfig(AppConfig config)
|
||||
{
|
||||
// Atomic write: serialize to a temp file in the same directory, then File.Replace it
|
||||
// over the real config. On NTFS, File.Replace is atomic — a crash mid-write leaves the
|
||||
// previous config intact and the new one discarded, not a truncated file. The Replace
|
||||
// also produces the .bak that LoadConfig falls back to. The first-ever save (no
|
||||
// destination yet) uses the temp-rename path since File.Replace requires an existing
|
||||
// destination.
|
||||
string json = JsonSerializer.Serialize(config, JSON_OPTS);
|
||||
string tmp = CONFIG_PATH + ".tmp";
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(Path.GetDirectoryName(CONFIG_PATH)!);
|
||||
File.WriteAllText(tmp, json);
|
||||
|
||||
if (File.Exists(CONFIG_PATH))
|
||||
File.Replace(tmp, CONFIG_PATH, CONFIG_BAK_PATH);
|
||||
else
|
||||
File.Move(tmp, CONFIG_PATH);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
finally
|
||||
{
|
||||
// Clean up a stray temp file if Replace/Move threw before consuming it.
|
||||
try { if (File.Exists(tmp)) File.Delete(tmp); } catch { }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace AutoIngest.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// In-memory view of the opt-in device registry persisted in <see cref="AppConfig"/>.
|
||||
/// Only devices whose <see cref="DeviceIdentity.Id"/> matches an entry here are imported
|
||||
/// automatically; everything else is prompted or skipped per <see cref="AppConfig.PromptOnUnknown"/>.
|
||||
/// </summary>
|
||||
public class DeviceRegistry
|
||||
{
|
||||
readonly AppConfig _config;
|
||||
|
||||
public DeviceRegistry(AppConfig config)
|
||||
{
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>True if a device with the given fingerprint has been registered.</summary>
|
||||
public bool IsRegistered(string deviceId) =>
|
||||
_config.Devices.Any(d => string.Equals(d.Id, deviceId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
public RegisteredDevice? Find(string deviceId) =>
|
||||
_config.Devices.FirstOrDefault(d =>
|
||||
string.Equals(d.Id, deviceId, StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
/// <summary>Registers a device. No-op if already present (updates name/kind if changed).</summary>
|
||||
public void Register(string id, string name, string kind)
|
||||
{
|
||||
var existing = Find(id);
|
||||
if (existing != null)
|
||||
{
|
||||
existing.Name = name;
|
||||
existing.Kind = kind;
|
||||
return;
|
||||
}
|
||||
_config.Devices.Add(new RegisteredDevice
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Kind = kind,
|
||||
AddedUtc = DateTime.UtcNow
|
||||
});
|
||||
}
|
||||
|
||||
public bool Remove(string deviceId)
|
||||
{
|
||||
var existing = Find(deviceId);
|
||||
return existing != null && _config.Devices.Remove(existing);
|
||||
}
|
||||
|
||||
public IReadOnlyList<RegisteredDevice> All => _config.Devices;
|
||||
|
||||
public void Save() => ConfigManager.SaveConfig(_config);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
|
||||
namespace AutoIngest.Core
|
||||
{
|
||||
/// <summary>
|
||||
/// A minimal append-only, rolling text-file logger. The on-screen toast log vanishes on
|
||||
/// restart; this mirrors every line to disk so a "my photos disappeared" report is diagnosable
|
||||
/// after the fact. Lives at <c>%LocalAppData%\AutoIngest\autoingest.log</c> (per-user, does
|
||||
/// not roam). Rolls to a single <c>.bak</c> at ~1 MB so it never grows unbounded.
|
||||
///
|
||||
/// Thread-safe: the import coordinator raises log events on its background thread; the file
|
||||
/// is written under a lock so concurrent appends serialize cleanly.
|
||||
/// </summary>
|
||||
public static class FileLogger
|
||||
{
|
||||
static readonly string LogDir = Path.Combine(
|
||||
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
|
||||
"AutoIngest");
|
||||
|
||||
public static string LogPath => Path.Combine(LogDir, "autoingest.log");
|
||||
static string BakPath => Path.Combine(LogDir, "autoingest.log.bak");
|
||||
|
||||
const long MaxBytes = 1_048_576; // 1 MB — roll before the file gets unwieldy
|
||||
static readonly object _lock = new();
|
||||
|
||||
/// <summary>
|
||||
/// Appends one timestamped line. Best-effort: a log-write failure (full disk, locked file)
|
||||
/// is swallowed — the logger must never throw into its caller, especially not into the
|
||||
/// import pipeline.
|
||||
/// </summary>
|
||||
public static void Log(string message)
|
||||
{
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
Directory.CreateDirectory(LogDir);
|
||||
RollIfNeeded();
|
||||
File.AppendAllText(LogPath,
|
||||
$"[{DateTime.Now:yyyy-MM-dd HH:mm:ss}] {message}{Environment.NewLine}",
|
||||
Encoding.UTF8);
|
||||
}
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
/// <summary>Logs an exception with its type and stack trace, for unhandled-exception handlers.</summary>
|
||||
public static void LogException(string context, Exception ex)
|
||||
=> Log($"{context}: {ex.GetType().Name}: {ex.Message}{Environment.NewLine}{ex.StackTrace}");
|
||||
|
||||
/// <summary>
|
||||
/// Renames the current log to .bak (overwriting any prior .bak) once it crosses the size
|
||||
/// threshold. The directory therefore holds at most two log files: the active one and one
|
||||
/// backup — bounded disk use, predictable rotation.
|
||||
/// </summary>
|
||||
static void RollIfNeeded()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!File.Exists(LogPath)) return;
|
||||
if (new FileInfo(LogPath).Length < MaxBytes) return;
|
||||
if (File.Exists(BakPath)) File.Delete(BakPath);
|
||||
File.Move(LogPath, BakPath);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
using System;
|
||||
using System.Diagnostics;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Deals with the awkward reality that Windows disables <c>LastAccessTime</c> updates by
|
||||
/// default (since Vista, for performance). Retention can still work using
|
||||
/// <c>LastWriteTime</c> (import date), but true "was this folder opened recently?" tracking
|
||||
/// needs access times to be on. This class detects the current state and offers to enable
|
||||
/// it (which requires admin + a restart to fully take effect).
|
||||
///
|
||||
/// We never silently change system settings. The UI shows the status and, if the user opts
|
||||
/// in, launches an elevated <c>fsutil</c> that Windows will prompt UAC for.
|
||||
/// </summary>
|
||||
public static class AccessTimeTracker
|
||||
{
|
||||
/// <summary>
|
||||
/// Whether NTFS is currently updating LastAccessTime on file reads. Conservative: only
|
||||
/// returns true when we get a clear "enabled" signal from fsutil. Unknown / system-managed
|
||||
/// modes return false so the UI degrades to the LastWriteTime fallback.
|
||||
/// </summary>
|
||||
public static bool IsEnabled()
|
||||
{
|
||||
try
|
||||
{
|
||||
var (output, exitCode) = RunFsutil("behavior query DisableLastAccess");
|
||||
if (exitCode != 0 || string.IsNullOrWhiteSpace(output)) return false;
|
||||
return ParseDisableLastAccess(output);
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attempts to enable access-time updates by launching an elevated fsutil. Returns true
|
||||
/// if the elevated process was launched (the UAC prompt was accepted); false if the user
|
||||
/// declined elevation or launch failed. The actual effect requires a reboot, which the
|
||||
/// caller is responsible for communicating.
|
||||
/// </summary>
|
||||
public static bool TryEnable()
|
||||
{
|
||||
try
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "fsutil.exe",
|
||||
Arguments = "behavior set DisableLastAccess 0",
|
||||
Verb = "runas", // triggers UAC
|
||||
UseShellExecute = true, // required for Verb = runas
|
||||
WindowStyle = ProcessWindowStyle.Hidden,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
if (p == null) return false;
|
||||
// Don't block forever — fsutil is fast, but UAC might be pending. 30s is plenty
|
||||
// for the user to respond to the prompt.
|
||||
return p.WaitForExit(30000) && p.ExitCode == 0;
|
||||
}
|
||||
catch (System.ComponentModel.Win32Exception)
|
||||
{
|
||||
// User cancelled the UAC prompt (ERROR_CANCELLED).
|
||||
return false;
|
||||
}
|
||||
catch
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Parses <c>fsutil behavior query DisableLastAccess</c> output.
|
||||
/// Historical values:
|
||||
/// 0 = enabled (updating access times)
|
||||
/// 1 = disabled (the Vista+ default)
|
||||
/// 80000003 = system-managed (Win10 1803+); may or may not update — treat as off
|
||||
/// The output line looks like "DisableLastAccess = 1" or includes a note line.
|
||||
/// </summary>
|
||||
static bool ParseDisableLastAccess(string output)
|
||||
{
|
||||
foreach (var raw in output.Split(new[] { '\r', '\n' }, StringSplitOptions.RemoveEmptyEntries))
|
||||
{
|
||||
var line = raw.Trim();
|
||||
int eq = line.IndexOf('=');
|
||||
if (eq < 0) continue;
|
||||
var value = line.Substring(eq + 1).Trim();
|
||||
// Take the leading integer token (ignore trailing notes).
|
||||
var digits = new System.Text.StringBuilder();
|
||||
foreach (var ch in value)
|
||||
{
|
||||
if (char.IsDigit(ch)) digits.Append(ch);
|
||||
else if (digits.Length > 0) break;
|
||||
}
|
||||
if (digits.Length == 0) continue;
|
||||
if (!int.TryParse(digits.ToString(), out var n)) continue;
|
||||
// 0 = enabled. Anything else (1, system-managed codes) = treat as disabled.
|
||||
return n == 0;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
static (string output, int exitCode) RunFsutil(string args)
|
||||
{
|
||||
var psi = new ProcessStartInfo
|
||||
{
|
||||
FileName = "fsutil.exe",
|
||||
Arguments = args,
|
||||
UseShellExecute = false,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
CreateNoWindow = true
|
||||
};
|
||||
using var p = Process.Start(psi);
|
||||
if (p == null) return ("", -1);
|
||||
var stdout = p.StandardOutput.ReadToEnd();
|
||||
p.WaitForExit(5000);
|
||||
return (stdout, p.ExitCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,184 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AutoIngest.Core;
|
||||
using ImageMagick;
|
||||
using ImageMagick.Drawing;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Burns visible branding into a photo in place. Called from <see cref="ImageConverter"/>
|
||||
/// BEFORE metadata stripping (watermarks are pixels, not metadata, so they survive anything),
|
||||
/// and BEFORE EXIF copyright is written. Two independent overlays:
|
||||
///
|
||||
/// - Logo: a PNG composited onto the image at an anchor, with adjustable opacity and an
|
||||
/// optional scale-to-width. Drawn first so the text can sit on top of it.
|
||||
/// - Text: the seller's store name / handle / URL drawn as a single line at an anchor,
|
||||
/// with adjustable opacity baked into the fill color.
|
||||
///
|
||||
/// Anchors use percentage-based margins (not fixed pixels) so the watermark lands sensibly on
|
||||
/// any photo size. Everything is wrapped: a missing logo file, an unreadable image, or a font
|
||||
/// problem logs and skips that one overlay — never throws out of the convert pipeline.
|
||||
/// </summary>
|
||||
public static class BrandingRenderer
|
||||
{
|
||||
const double MARGIN_PCT = 0.02; // 2% of image dimension, inset from the chosen edge/corner
|
||||
|
||||
/// <summary>
|
||||
/// Applies whichever overlays <paramref name="branding"/> enables to <paramref name="image"/>.
|
||||
/// No-op (and safe) when branding is disabled or both overlays are off. Mutates the image
|
||||
/// in place.
|
||||
/// </summary>
|
||||
public static void Apply(MagickImage image, BrandingConfig? branding)
|
||||
{
|
||||
if (branding == null || !branding.Enabled) return;
|
||||
|
||||
if (branding.LogoWatermarkEnabled)
|
||||
TryApplyLogo(image, branding);
|
||||
|
||||
if (branding.TextWatermarkEnabled)
|
||||
TryApplyText(image, branding);
|
||||
}
|
||||
|
||||
static void TryApplyLogo(MagickImage image, BrandingConfig b)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(b.LogoPath) || !File.Exists(b.LogoPath))
|
||||
return;
|
||||
|
||||
try
|
||||
{
|
||||
using var logo = new MagickImage(b.LogoPath);
|
||||
|
||||
// Scale: either to a % of the host image's width, or leave at native size. Clamp
|
||||
// the scale so a fat logo can't exceed 60% of the photo width (keeps it a mark,
|
||||
// not a takeover). image.Width is uint; work in int after the cast.
|
||||
int hostW = (int)image.Width;
|
||||
if (b.LogoScalePercent > 0)
|
||||
{
|
||||
int targetW = (int)(hostW * (b.LogoScalePercent / 100.0));
|
||||
int maxW = (int)(hostW * 0.6);
|
||||
if (targetW > maxW) targetW = maxW;
|
||||
if (targetW > 0 && Math.Abs((int)logo.Width - targetW) > 1)
|
||||
logo.Scale((uint)targetW, 0);
|
||||
}
|
||||
|
||||
// Opacity: multiply the alpha channel by a fraction in [0.05, 1.0]. Magick.NET's
|
||||
// Evaluate with a double is the cleanest overload (Percentage ctor is explicit).
|
||||
double frac = Clamp(b.LogoOpacity / 100.0, 0.05, 1.0);
|
||||
if (frac < 1.0)
|
||||
{
|
||||
logo.HasAlpha = true;
|
||||
logo.Evaluate(Channels.Alpha, EvaluateOperator.Multiply, frac);
|
||||
}
|
||||
|
||||
var (x, y) = Anchor((int)image.Width, (int)image.Height,
|
||||
(int)logo.Width, (int)logo.Height, b.LogoPosition);
|
||||
image.Composite(logo, x, y, CompositeOperator.Over);
|
||||
}
|
||||
catch (MagickException)
|
||||
{
|
||||
// Bad/corrupt logo file — skip the overlay, keep converting.
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
// Logo file existed at the check but couldn't be read (locked, etc.) — skip.
|
||||
}
|
||||
}
|
||||
|
||||
static void TryApplyText(MagickImage image, BrandingConfig b)
|
||||
{
|
||||
string text = BuildText(b);
|
||||
if (string.IsNullOrWhiteSpace(text)) return;
|
||||
|
||||
try
|
||||
{
|
||||
int hostW = (int)image.Width;
|
||||
int hostH = (int)image.Height;
|
||||
|
||||
// Point size scales with the image so the watermark reads on both a 4000px and a
|
||||
// 800px photo. Floor at 12 so it can't vanish on tiny images.
|
||||
double pointSize = Math.Max(12, hostW * 0.022);
|
||||
|
||||
// Opacity → alpha fraction in [0.05, 1.0]; bake into the fill color's alpha byte.
|
||||
double frac = Clamp(b.TextOpacity / 100.0, 0.05, 1.0);
|
||||
var fill = new MagickColor("white") { A = (ushort)(Quantum.Max * frac) };
|
||||
var shadow = new MagickColor("black") { A = (ushort)(Quantum.Max * frac) };
|
||||
|
||||
// Measure the text so we can anchor the box. FontTypeMetrics needs the same point
|
||||
// size we'll draw with; chain it off a Drawables configured identically.
|
||||
var metrics = new Drawables()
|
||||
.FontTypeMetrics(text);
|
||||
|
||||
// FontTypeMetrics can return null if the font machinery can't measure (e.g. no
|
||||
// font available). Fall back to a rough estimate so we still place something.
|
||||
double textWidth = metrics?.TextWidth ?? text.Length * pointSize * 0.5;
|
||||
double textHeight = metrics?.TextHeight ?? pointSize * 1.2;
|
||||
double ascent = metrics?.Ascent ?? pointSize;
|
||||
|
||||
int tw = (int)Math.Ceiling(textWidth);
|
||||
int th = (int)Math.Ceiling(textHeight);
|
||||
var (tx, boxTop) = Anchor(hostW, hostH, tw, th, b.TextPosition);
|
||||
|
||||
// Text() takes the baseline origin; Anchor gave us the box top, so add the ascent.
|
||||
double baselineY = boxTop + ascent;
|
||||
|
||||
// Shadow first (offset 1px down-right for legibility), then the main fill on top.
|
||||
new Drawables()
|
||||
.FontPointSize(pointSize)
|
||||
.FillColor(shadow)
|
||||
.Text(tx + 1, baselineY + 1, text)
|
||||
.Draw(image);
|
||||
|
||||
new Drawables()
|
||||
.FontPointSize(pointSize)
|
||||
.FillColor(fill)
|
||||
.Text(tx, baselineY, text)
|
||||
.Draw(image);
|
||||
}
|
||||
catch (MagickException)
|
||||
{
|
||||
// Font/render problem — skip the text overlay, keep converting.
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds the single watermark line from whichever store fields are populated.
|
||||
/// e.g. "ReelDeals · @reeldeals · ebay.com/usr/reeldeals". Empty parts are skipped.
|
||||
/// </summary>
|
||||
static string BuildText(BrandingConfig b)
|
||||
{
|
||||
var parts = new System.Collections.Generic.List<string>();
|
||||
if (!string.IsNullOrWhiteSpace(b.StoreName)) parts.Add(b.StoreName.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(b.SellerHandle))
|
||||
{
|
||||
string h = b.SellerHandle.Trim();
|
||||
if (!h.StartsWith("@", StringComparison.Ordinal)) h = "@" + h;
|
||||
parts.Add(h);
|
||||
}
|
||||
if (!string.IsNullOrWhiteSpace(b.StoreUrl)) parts.Add(b.StoreUrl.Trim());
|
||||
return string.Join(" \u00b7 ", parts); // middle dot separator
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the top-left pixel of a box of size (w,h) placed at <paramref name="pos"/>
|
||||
/// within a host of size (hostW, hostH), with a percentage margin from the edges.
|
||||
/// </summary>
|
||||
static (int x, int y) Anchor(int hostW, int hostH, int w, int h, WatermarkPosition pos)
|
||||
{
|
||||
int mx = (int)(hostW * MARGIN_PCT);
|
||||
int my = (int)(hostH * MARGIN_PCT);
|
||||
return pos switch
|
||||
{
|
||||
WatermarkPosition.TopLeft => (mx, my),
|
||||
WatermarkPosition.TopRight => (hostW - w - mx, my),
|
||||
WatermarkPosition.BottomLeft => (mx, hostH - h - my),
|
||||
WatermarkPosition.BottomRight => (hostW - w - mx, hostH - h - my),
|
||||
WatermarkPosition.Center => ((hostW - w) / 2, (hostH - h) / 2),
|
||||
_ => (hostW - w - mx, hostH - h - my),
|
||||
};
|
||||
}
|
||||
|
||||
static double Clamp(double v, double min, double max) =>
|
||||
v < min ? min : v > max ? max : v;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// A connected device as seen by an <see cref="IDeviceSource"/>. <see cref="Id"/> is the
|
||||
/// stable fingerprint persisted in the device registry; <see cref="Photos"/> is the live
|
||||
/// accessor for this connection.
|
||||
/// </summary>
|
||||
public class DeviceIdentity
|
||||
{
|
||||
/// <summary>
|
||||
/// Stable, source-specific fingerprint used as the registry key. Format conventions:
|
||||
/// - SD/USB mass storage: "MS:{volumeSerialHex}:{labelToUpper}" (volume serial from
|
||||
/// GetVolumeInformationW, which survives reboots and drive-letter reassignment).
|
||||
/// - MTP phone: "MTP:{serialOrPnPId}:{nameToUpper}".
|
||||
/// - Weak fallback (no serial available): "WEAK:{label}:{totalBytes}" — flagged so the
|
||||
/// UI can warn that this id may collide across physically different devices.
|
||||
/// </summary>
|
||||
public string Id { get; init; } = "";
|
||||
|
||||
/// <summary>Human-friendly name for prompts, logs, and the registry UI.</summary>
|
||||
public string DisplayName { get; init; } = "";
|
||||
|
||||
/// <summary>"SD", "USB", "USB-weak", or "Phone". Drives icon/label in the UI.</summary>
|
||||
public string Kind { get; init; } = "";
|
||||
|
||||
/// <summary>Live photo accessor for this connection. Valid only while connected.</summary>
|
||||
public IPhotoProvider Photos { get; init; } = null!;
|
||||
|
||||
public override string ToString() => $"{Kind}:{DisplayName}";
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,373 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using AutoIngest.Core;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// The device-agnostic import pipeline, extracted from the old SDCardMonitor. Given any
|
||||
/// connected device (mass storage or MTP phone) it:
|
||||
/// 1. Enumerates photos via the device's <see cref="IPhotoProvider"/>.
|
||||
/// 2. Moves JPGs straight to ~/Pictures/<today>/, converts everything else via
|
||||
/// Magick.NET, then removes the source via the provider.
|
||||
/// 3. Applies the same-day dupe rules (size then SHA-256) scoped to today's folder.
|
||||
///
|
||||
/// The pipeline never touches the device directly — all enumeration and source removal go
|
||||
/// through <see cref="IPhotoProvider"/>, so mass storage (File.Move/File.Delete) and MTP
|
||||
/// (shell copy-to-temp / shell delete-object) share this exact code path.
|
||||
/// </summary>
|
||||
public class DeviceImporter
|
||||
{
|
||||
const int MAX_COLLISION_SUFFIX = 9999;
|
||||
const int MAX_COPY_COLLISION_RETRIES = 12; // re-resolve cap for CopyNoClobber races
|
||||
|
||||
static readonly HashSet<string> SKIP_FOLDERS = new(StringComparer.OrdinalIgnoreCase) {
|
||||
"System Volume Information", "$RECYCLE.BIN",
|
||||
"Windows", "Program Files", "Program Files (x86)",
|
||||
"ProgramData", "AppData", "Recovery"
|
||||
};
|
||||
|
||||
static readonly HashSet<string> SKIP_PREFIXES = new() { ".", "$", "~" };
|
||||
|
||||
static readonly string[] DCIM_PROBES = { "DCIM", "Pictures", "photos", "PICTURES" };
|
||||
|
||||
public static readonly HashSet<string> IMAGE_EXTENSIONS = new(StringComparer.OrdinalIgnoreCase) {
|
||||
".jpg", ".jpeg", ".png", ".gif", ".bmp", ".tiff", ".tif", ".webp",
|
||||
".raw", ".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2",
|
||||
".gpr", ".pef", ".srw", ".srf", ".sr2", ".raf",
|
||||
".heic", ".heif",
|
||||
".ico", ".ppm", ".pgm", ".pbm", ".svg"
|
||||
};
|
||||
|
||||
public event Action<string, string>? DriveFound; // (deviceId, label)
|
||||
public event Action<int, int, string>? ImportProgress; // (current, total, filename)
|
||||
public event Action<string>? ImportError; // (message)
|
||||
public event Action<string, string>? LogMessage; // (message, hexColor)
|
||||
|
||||
readonly ImageConverter _converter;
|
||||
// Config is read live per Import() call so the tray Options toggles
|
||||
// (auto-orient, strip EXIF, JPG quality) take effect on the next import without a restart.
|
||||
readonly AppConfig _config;
|
||||
|
||||
public DeviceImporter(ImageConverter converter, AppConfig config)
|
||||
{
|
||||
_converter = converter;
|
||||
_config = config;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Imports all photos from <paramref name="device"/> into ~/Pictures/<today>/.
|
||||
/// Returns counts; raises events as it goes. Safe to call on any device whose
|
||||
/// <see cref="IPhotoProvider"/> can enumerate.
|
||||
/// </summary>
|
||||
public (int moved, int converted, int total, string sizeStr, string destFolder)
|
||||
Import(DeviceIdentity device)
|
||||
{
|
||||
var photosBase = Environment.GetFolderPath(Environment.SpecialFolder.MyPictures);
|
||||
|
||||
var photos = device.Photos.EnumeratePhotos();
|
||||
if (photos.Count == 0)
|
||||
return (0, 0, 0, "", "");
|
||||
|
||||
string today = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
string destFolder = Path.Combine(photosBase, today);
|
||||
Directory.CreateDirectory(destFolder);
|
||||
|
||||
DriveFound?.Invoke(device.Id, $"{photos.Count} photos found on {device.DisplayName}");
|
||||
|
||||
int moved = 0, converted = 0, skipped = 0;
|
||||
long totalBytes = 0;
|
||||
|
||||
foreach (var (src, idx) in photos.Select((f, i) => (f, i)))
|
||||
{
|
||||
string filename = Path.GetFileName(src);
|
||||
ImportProgress?.Invoke(idx + 1, photos.Count, filename);
|
||||
|
||||
try
|
||||
{
|
||||
string ext = Path.GetExtension(src).ToLowerInvariant();
|
||||
|
||||
if (ext is ".jpg" or ".jpeg")
|
||||
{
|
||||
// Resolve the destination, comparing against a same-named file in
|
||||
// TODAY'S folder only: same size + same hash => genuine dupe (skip);
|
||||
// otherwise rename with a suffix and move.
|
||||
string destPath = ResolveDestPath(destFolder, filename, src);
|
||||
if (destPath == "")
|
||||
{
|
||||
skipped++;
|
||||
Log($"Skipped (duplicate of file already imported today): {filename}", "#8b949e");
|
||||
// Still reclaim any staged temp copy (MTP) so it doesn't leak.
|
||||
device.Photos.RemoveSource(src);
|
||||
continue;
|
||||
}
|
||||
|
||||
long srcLen = new FileInfo(src).Length;
|
||||
// The provider removes the source; for mass storage that's a move, for
|
||||
// MTP it's copy-then-delete. Either way we copy first then ask the
|
||||
// provider to reclaim the source so the verify-before-delete guarantee
|
||||
// holds identically for both device kinds.
|
||||
//
|
||||
// Copy without overwrite: ResolveDestPath already guaranteed the path was
|
||||
// free, so a collision here means another process (or a race) created it
|
||||
// in the gap — re-resolve with a suffix rather than silently clobbering.
|
||||
CopyNoClobber(src, destFolder, ref destPath);
|
||||
|
||||
if (!File.Exists(destPath) || new FileInfo(destPath).Length != srcLen)
|
||||
throw new IOException("move verification failed (size mismatch)");
|
||||
|
||||
device.Photos.RemoveSource(src);
|
||||
|
||||
moved++;
|
||||
totalBytes += srcLen;
|
||||
Log(string.Equals(destPath, Path.Combine(destFolder, filename), StringComparison.OrdinalIgnoreCase)
|
||||
? $"Moved: {filename}"
|
||||
: $"Moved: {filename} -> {Path.GetFileName(destPath)} (name in use today)",
|
||||
"#3fb950");
|
||||
}
|
||||
else
|
||||
{
|
||||
// Convert to JPG, then delete the original from the device. The output
|
||||
// name is resolved against today's folder (two RAWs may share a stem;
|
||||
// only an identical converted output is a real dupe).
|
||||
string destName = Path.GetFileNameWithoutExtension(src) + ".jpg";
|
||||
string destPath = ResolveDestPath(destFolder, destName, src);
|
||||
if (destPath == "")
|
||||
{
|
||||
skipped++;
|
||||
Log($"Skipped (duplicate output already imported today): {filename}", "#8b949e");
|
||||
device.Photos.RemoveSource(src);
|
||||
continue;
|
||||
}
|
||||
|
||||
long srcLen = new FileInfo(src).Length;
|
||||
try
|
||||
{
|
||||
_converter.ConvertToJpg(src, destPath,
|
||||
_config.JpgQuality, _config.AutoOrient, _config.StripExif,
|
||||
_config.Branding);
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Conversion failed mid-write: delete any partial output so it can't be
|
||||
// mistaken for a successful prior import later, then rethrow.
|
||||
TryDelete(destPath);
|
||||
throw;
|
||||
}
|
||||
|
||||
// Verify destination exists with non-zero size before removing the source.
|
||||
long size = new FileInfo(destPath).Length;
|
||||
if (size <= 0)
|
||||
{
|
||||
TryDelete(destPath);
|
||||
throw new IOException("conversion produced an empty file");
|
||||
}
|
||||
|
||||
// Stronger verify: re-decode the output. A partial/corrupt JPG that happens
|
||||
// to be non-zero would otherwise pass the size check and — for phones with
|
||||
// delete-from-phone on — lead to the original being removed with no good
|
||||
// local copy. CanRead re-opens with Magick and confirms it's a valid image.
|
||||
if (!_converter.CanRead(destPath))
|
||||
{
|
||||
TryDelete(destPath);
|
||||
throw new IOException("conversion output failed to decode (corrupt result)");
|
||||
}
|
||||
|
||||
bool removed = device.Photos.RemoveSource(src);
|
||||
converted++;
|
||||
totalBytes += size;
|
||||
Log(removed
|
||||
? $"Converted: {filename} -> JPG ({size / 1024.0:F1} KB) [src {srcLen / 1024.0:F0} KB removed]"
|
||||
: $"Converted: {filename} -> JPG ({size / 1024.0:F1} KB) [src kept: {srcLen / 1024.0:F0} KB]",
|
||||
removed ? "#58a6ff" : "#d29922");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log($"{filename}: {ex.Message}", "#f85149");
|
||||
ImportError?.Invoke($"{filename}: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
if (skipped > 0)
|
||||
Log($"Skipped {skipped} duplicate file(s) already imported today.", "#8b949e");
|
||||
|
||||
string sizeStr = FileSize.Format(totalBytes);
|
||||
return (moved, converted, moved + converted, sizeStr, destFolder);
|
||||
}
|
||||
|
||||
// ---- Mass-storage photo discovery (used by the SD/USB source) -----------------
|
||||
|
||||
/// <summary>
|
||||
/// Probes a drive-letter root for photos: DCIM/Pictures first, then root files, then
|
||||
/// a bounded recursive scan. Skips system folders and hidden/prefixed names. Shared
|
||||
/// here so any future mass-storage-like source reuses it.
|
||||
/// </summary>
|
||||
public static List<string> FindPhotosOnRoot(string root)
|
||||
{
|
||||
var seen = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
var dcimResults = DCIM_PROBES
|
||||
.Select(p => Path.Combine(root, p))
|
||||
.Where(Directory.Exists)
|
||||
.SelectMany(p => ScanFolder(p, 10))
|
||||
.Where(fp => seen.Add(fp))
|
||||
.ToList();
|
||||
if (dcimResults.Count > 0) return dcimResults;
|
||||
|
||||
List<string> rootResults = new();
|
||||
try
|
||||
{
|
||||
rootResults = Directory.EnumerateFiles(root)
|
||||
.Where(f => seen.Add(f) && IsImageFile(f))
|
||||
.ToList();
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
if (rootResults.Count > 0) return rootResults;
|
||||
|
||||
return ScanFolder(root, 3).Where(fp => seen.Add(fp)).ToList();
|
||||
}
|
||||
|
||||
static List<string> ScanFolder(string folder, int maxDepth)
|
||||
{
|
||||
var results = new List<string>();
|
||||
try { ScanRecursive(folder, maxDepth, 0, results); }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
return results;
|
||||
}
|
||||
|
||||
static void ScanRecursive(string dir, int maxDepth, int depth, List<string> results)
|
||||
{
|
||||
if (depth > maxDepth) return;
|
||||
|
||||
string[] dirs; string[] files;
|
||||
try { dirs = Directory.GetDirectories(dir); files = Directory.GetFiles(dir); }
|
||||
catch (UnauthorizedAccessException) { return; }
|
||||
catch (IOException) { return; }
|
||||
|
||||
dirs = dirs.Where(d =>
|
||||
{
|
||||
string name = Path.GetFileName(d);
|
||||
return !SKIP_FOLDERS.Contains(name) &&
|
||||
!SKIP_PREFIXES.Any(p => name.StartsWith(p));
|
||||
}).ToArray();
|
||||
|
||||
results.AddRange(files.Where(f =>
|
||||
{
|
||||
string name = Path.GetFileName(f);
|
||||
return !SKIP_PREFIXES.Any(p => name.StartsWith(p)) && IsImageFile(f);
|
||||
}));
|
||||
|
||||
foreach (var d in dirs) ScanRecursive(d, maxDepth, depth + 1, results);
|
||||
}
|
||||
|
||||
// ---- Destination resolution / dedupe (scoped to today's folder) --------------
|
||||
|
||||
/// <summary>
|
||||
/// Resolves a destination path inside <paramref name="destFolder"/> for a source
|
||||
/// file that wants to be named <paramref name="desiredName"/>.
|
||||
///
|
||||
/// Dupe rules (scoped to TODAY'S folder only — we are not a cross-day dedupe tool):
|
||||
/// - No existing file with the desired name -> use the desired name.
|
||||
/// - Existing file, different size -> not a dupe; rename with a suffix.
|
||||
/// - Existing file, same size, same hash -> genuine duplicate; return "" (skip).
|
||||
/// - Existing file, same size, different hash-> not a dupe; rename with a suffix.
|
||||
///
|
||||
/// Returns the resolved destination path, or "" if the source is a byte-for-byte
|
||||
/// duplicate of what's already there and should be skipped.
|
||||
/// </summary>
|
||||
static string ResolveDestPath(string destFolder, string desiredName, string sourceFile)
|
||||
{
|
||||
string desired = Path.Combine(destFolder, desiredName);
|
||||
if (!File.Exists(desired)) return desired;
|
||||
|
||||
long srcLen = new FileInfo(sourceFile).Length;
|
||||
long dstLen = new FileInfo(desired).Length;
|
||||
|
||||
if (srcLen != dstLen) return MakeUniquePath(destFolder, desiredName);
|
||||
if (FilesEqual(sourceFile, desired)) return "";
|
||||
return MakeUniquePath(destFolder, desiredName);
|
||||
}
|
||||
|
||||
/// <summary>True if the two files are byte-for-byte identical (SHA256 match).</summary>
|
||||
static bool FilesEqual(string a, string b)
|
||||
{
|
||||
try
|
||||
{
|
||||
using var ha = SHA256.Create();
|
||||
using var hb = SHA256.Create();
|
||||
using var fa = File.OpenRead(a);
|
||||
using var fb = File.OpenRead(b);
|
||||
return ha.ComputeHash(fa).SequenceEqual(hb.ComputeHash(fb));
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return false; }
|
||||
catch (IOException) { return false; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns a non-colliding path in <paramref name="folder"/> by appending
|
||||
/// _1, _2, ... to the stem until a free name is found.
|
||||
/// </summary>
|
||||
static string MakeUniquePath(string folder, string desiredName)
|
||||
{
|
||||
string path = Path.Combine(folder, desiredName);
|
||||
if (!File.Exists(path)) return path;
|
||||
|
||||
string stem = Path.GetFileNameWithoutExtension(desiredName);
|
||||
string ext = Path.GetExtension(desiredName);
|
||||
|
||||
foreach (int n in Enumerable.Range(1, MAX_COLLISION_SUFFIX))
|
||||
{
|
||||
path = Path.Combine(folder, $"{stem}_{n}{ext}");
|
||||
if (!File.Exists(path)) return path;
|
||||
}
|
||||
return Path.Combine(folder, $"{stem}_{Guid.NewGuid():N}{ext}");
|
||||
}
|
||||
|
||||
public static bool IsImageFile(string path) => IMAGE_EXTENSIONS.Contains(Path.GetExtension(path));
|
||||
|
||||
/// <summary>
|
||||
/// Copies <paramref name="src"/> into <paramref name="destFolder"/> at
|
||||
/// <paramref name="destPath"/> WITHOUT overwriting. If the target was created by another
|
||||
/// process in the TOCTOU window between ResolveDestPath and now, re-resolves to a fresh
|
||||
/// unique name and updates <paramref name="destPath"/>. Never silently clobbers an
|
||||
/// existing file. Throws on non-collision IOExceptions (e.g. disk full).
|
||||
/// </summary>
|
||||
static void CopyNoClobber(string src, string destFolder, ref string destPath)
|
||||
{
|
||||
for (int attempt = 0; ; attempt++)
|
||||
{
|
||||
try
|
||||
{
|
||||
File.Copy(src, destPath, overwrite: false);
|
||||
return;
|
||||
}
|
||||
catch (IOException) when (attempt < MAX_COPY_COLLISION_RETRIES)
|
||||
{
|
||||
// Collision (file exists) — re-resolve. Other IOExceptions also land here but
|
||||
// the re-resolve is harmless and the loop bound prevents infinite spin; a real
|
||||
// disk error will exhaust attempts and propagate.
|
||||
destPath = MakeUniquePath(destFolder, Path.GetFileName(destPath));
|
||||
if (destPath == "")
|
||||
throw; // shouldn't happen, but bail if MakeUniquePath returns ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Best-effort delete; swallows the usual filesystem failures. Used to clean up
|
||||
/// partial convert outputs without masking the real error.</summary>
|
||||
static void TryDelete(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
void Log(string msg, string color) => LogMessage?.Invoke(msg, color);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
using System;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Human-readable byte formatting — one source of truth used by the import pipeline, the
|
||||
/// retention sweep, and the settings UI. Switch expression: single exit, no magic numbers
|
||||
/// inline at call sites, scales from bytes to gigabytes.
|
||||
/// </summary>
|
||||
public static class FileSize
|
||||
{
|
||||
const long KB = 1024;
|
||||
const long MB = 1024 * 1024;
|
||||
const long GB = 1024 * 1024 * 1024;
|
||||
|
||||
public static string Format(long bytes) => bytes switch
|
||||
{
|
||||
< KB => $"{bytes} B",
|
||||
< MB => $"{bytes / (double)KB:F1} KB",
|
||||
< GB => $"{bytes / (double)MB:F1} MB",
|
||||
_ => $"{bytes / (double)GB:F2} GB"
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Globalization;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using Microsoft.VisualBasic.FileIO;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Photo-folder retention: sends dated import folders
|
||||
/// (<c>~/Pictures/YYYY-MM-DD/</c>) that are older than the configured threshold to the
|
||||
/// Recycle Bin, EXCEPT when they've been accessed recently. The filesystem timestamps are
|
||||
/// the access record — no separate skip-list is maintained, because any access resets the
|
||||
/// clock automatically and the next sweep re-evaluates from scratch.
|
||||
///
|
||||
/// "Age" of a folder is the most recent activity across all of its files:
|
||||
/// - If access-time tracking is on → max(LastAccessTime) over files
|
||||
/// - Otherwise (Windows default) → max(LastWriteTime) over files (import/creation time)
|
||||
/// Using the max means "any file in the folder was touched → the whole folder counts as
|
||||
/// recently used," matching the spec's "skip the whole folder" semantics.
|
||||
///
|
||||
/// Safety rails (never deleted):
|
||||
/// - Today's folder.
|
||||
/// - Any folder whose name isn't a parseable YYYY-MM-DD date (e.g. _Archive, user folders).
|
||||
/// - Any folder newer than the threshold (by definition).
|
||||
/// Everything goes to the Recycle Bin, so a mistaken setting is recoverable.
|
||||
/// </summary>
|
||||
public static class FolderRetentionPolicy
|
||||
{
|
||||
/// <summary>
|
||||
/// Enumerates AutoIngest's own dated folders under <paramref name="picturesBase"/>,
|
||||
/// computing per-folder access time and size. Folders whose names don't parse as
|
||||
/// YYYY-MM-DD are skipped (they're not ours to manage).
|
||||
/// </summary>
|
||||
public static IEnumerable<DatedFolder> EnumerateDatedFolders(string picturesBase, bool preferAccessTime)
|
||||
{
|
||||
string[] dirs;
|
||||
try { dirs = Directory.GetDirectories(picturesBase); }
|
||||
catch (UnauthorizedAccessException) { yield break; }
|
||||
catch (IOException) { yield break; }
|
||||
|
||||
foreach (var dir in dirs)
|
||||
{
|
||||
string name = Path.GetFileName(dir);
|
||||
if (!DateTime.TryParseExact(name, "yyyy-MM-dd",
|
||||
CultureInfo.InvariantCulture, DateTimeStyles.None, out var folderDate))
|
||||
continue;
|
||||
|
||||
var (accessTime, fileCount, sizeBytes) = SummarizeFolder(dir, preferAccessTime);
|
||||
if (fileCount == 0) continue; // empty folder — leave it alone
|
||||
|
||||
yield return new DatedFolder
|
||||
{
|
||||
Path = dir,
|
||||
FolderDate = folderDate,
|
||||
AccessTime = accessTime,
|
||||
FileCount = fileCount,
|
||||
SizeBytes = sizeBytes
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs one retention pass. Returns the count of folders recycled and total bytes
|
||||
/// reclaimed. Every decision (recycle or skip) is reported via <paramref name="log"/>
|
||||
/// as (message, hexColor) pairs so the UI log shows the decision trail. Never throws —
|
||||
/// any per-folder error is logged and the sweep continues.
|
||||
/// </summary>
|
||||
public static (int recycled, long bytesReclaimed) Sweep(
|
||||
string picturesBase,
|
||||
int retentionDays,
|
||||
bool preferAccessTime,
|
||||
Action<string, string> log)
|
||||
{
|
||||
if (retentionDays <= 0)
|
||||
{
|
||||
log("Retention sweep skipped: threshold is zero/negative.", "#8b949e");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
string todayName = DateTime.Now.ToString("yyyy-MM-dd");
|
||||
var now = DateTime.Now;
|
||||
// Freshness guard: never recycle a folder whose most recent activity is within this
|
||||
// window. This is a safety net INDEPENDENT of the today-folder-name check — it catches
|
||||
// midnight rollover (an import that started at 23:58 writes to yesterday's folder,
|
||||
// which wouldn't match today's name but is clearly still active) and clock skew. 10
|
||||
// minutes is far shorter than any retention threshold but long enough to cover any
|
||||
// in-flight import at the moment the sweep fires.
|
||||
var freshnessWindow = TimeSpan.FromMinutes(10);
|
||||
int recycled = 0;
|
||||
long reclaimed = 0;
|
||||
|
||||
foreach (var folder in EnumerateDatedFolders(picturesBase, preferAccessTime))
|
||||
{
|
||||
// Never touch today's folder by name — it's actively being imported into.
|
||||
if (Path.GetFileName(folder.Path) == todayName)
|
||||
{
|
||||
log($"Skip (today): {Path.GetFileName(folder.Path)}", "#8b949e");
|
||||
continue;
|
||||
}
|
||||
|
||||
var age = now - folder.AccessTime;
|
||||
|
||||
// Freshness safety net: a folder written within the last few minutes is active
|
||||
// regardless of its name or nominal age. Protects rollover/skew edge cases.
|
||||
if (age < freshnessWindow)
|
||||
{
|
||||
log($"Skip (active): {Path.GetFileName(folder.Path)} — written {FormatAge(age)} ago (freshness guard)",
|
||||
"#8b949e");
|
||||
continue;
|
||||
}
|
||||
|
||||
if (age.TotalDays < retentionDays)
|
||||
{
|
||||
log($"Skip (in use): {Path.GetFileName(folder.Path)} — last activity {FormatAge(age)} ago",
|
||||
"#8b949e");
|
||||
continue;
|
||||
}
|
||||
|
||||
// Eligible. Log the intent first so the trail exists even if the recycle call
|
||||
// then fails for some reason.
|
||||
log($"Recycling: {Path.GetFileName(folder.Path)} — {folder.FileCount} files, {FileSize.Format(folder.SizeBytes)} (last activity {FormatAge(age)} ago)",
|
||||
"#d29922");
|
||||
|
||||
try
|
||||
{
|
||||
// SendToRecycleBin is the recoverable path. OnlyErrorDialogs means the user
|
||||
// only sees a dialog if something genuinely goes wrong (no progress noise).
|
||||
FileSystem.DeleteDirectory(folder.Path,
|
||||
UIOption.OnlyErrorDialogs,
|
||||
RecycleOption.SendToRecycleBin);
|
||||
recycled++;
|
||||
reclaimed += folder.SizeBytes;
|
||||
}
|
||||
catch (OperationCanceledException) { log($" canceled by user: {Path.GetFileName(folder.Path)}", "#f85149"); }
|
||||
catch (Exception ex) { log($" failed: {ex.Message}", "#f85149"); }
|
||||
}
|
||||
|
||||
if (recycled > 0)
|
||||
log($"Retention sweep complete: recycled {recycled} folder(s), reclaimed {FileSize.Format(reclaimed)}.",
|
||||
"#3fb950");
|
||||
else
|
||||
log("Retention sweep complete: nothing eligible.", "#58a6ff");
|
||||
|
||||
return (recycled, reclaimed);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Computes (latest activity time, file count, total bytes) for a folder. When
|
||||
/// <paramref name="preferAccessTime"/> is true we take the max of LastAccessTime across
|
||||
/// files; otherwise (or if access times look stale/zero) we fall back to LastWriteTime.
|
||||
/// We also take the max of the two as a defensive measure so a folder is never treated
|
||||
/// as older than its most recent write just because access tracking is off.
|
||||
/// </summary>
|
||||
static (DateTime accessTime, int fileCount, long sizeBytes) SummarizeFolder(
|
||||
string folder, bool preferAccessTime)
|
||||
{
|
||||
DateTime latest = DateTime.MinValue;
|
||||
int count = 0;
|
||||
long bytes = 0;
|
||||
|
||||
IEnumerable<string> files;
|
||||
try { files = Directory.EnumerateFiles(folder, "*", System.IO.SearchOption.AllDirectories); }
|
||||
catch (UnauthorizedAccessException) { return (DateTime.MinValue, 0, 0); }
|
||||
catch (IOException) { return (DateTime.MinValue, 0, 0); }
|
||||
|
||||
foreach (var f in files)
|
||||
{
|
||||
FileInfo fi;
|
||||
try { fi = new FileInfo(f); }
|
||||
catch { continue; }
|
||||
|
||||
count++;
|
||||
bytes += fi.Length;
|
||||
|
||||
// Take the most recent of access/write. If access tracking is off, LastAccessTime
|
||||
// is stale and the write time dominates — which is what we want.
|
||||
DateTime candidate = preferAccessTime
|
||||
? (fi.LastAccessTime > fi.LastWriteTime ? fi.LastAccessTime : fi.LastWriteTime)
|
||||
: fi.LastWriteTime;
|
||||
|
||||
if (candidate > latest) latest = candidate;
|
||||
}
|
||||
|
||||
return (latest, count, bytes);
|
||||
}
|
||||
|
||||
static string FormatAge(TimeSpan age)
|
||||
{
|
||||
if (age.TotalDays < 1) return $"{(int)age.TotalHours}h";
|
||||
if (age.TotalDays < 60) return $"{(int)age.TotalDays}d";
|
||||
return $"{(int)(age.TotalDays / 30.44):F0}mo";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Summary of one dated folder for retention decisions and UI display.</summary>
|
||||
public class DatedFolder
|
||||
{
|
||||
public string Path { get; set; } = "";
|
||||
public DateTime FolderDate { get; set; }
|
||||
/// <summary>Latest activity time across the folder's files (access or write).</summary>
|
||||
public DateTime AccessTime { get; set; }
|
||||
public int FileCount { get; set; }
|
||||
public long SizeBytes { get; set; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// A source of connected devices. The mass-storage source enumerates drive letters
|
||||
/// (SD cards / USB sticks); the MTP source enumerates portable devices (phones). The
|
||||
/// import coordinator polls every registered source each tick and imports from any
|
||||
/// device that passes the registry filter.
|
||||
/// </summary>
|
||||
public interface IDeviceSource
|
||||
{
|
||||
/// <summary>Snapshot of devices currently visible to this source.</summary>
|
||||
IEnumerable<DeviceIdentity> GetConnectedDevices();
|
||||
|
||||
/// <summary>Human label for log lines ("SD/USB", "Phone (MTP)").</summary>
|
||||
string SourceName { get; }
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
using System.Collections.Generic;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Abstraction over "how do I list and remove photos on this device", so the import
|
||||
/// pipeline is identical for mass-storage devices (SD/USB, where photos are plain files
|
||||
/// and removal is File.Delete) and MTP phones (where photos live behind a COM namespace
|
||||
/// and removal is a shell verb). The pipeline never touches the device directly; it
|
||||
/// enumerates via <see cref="EnumeratePhotos"/> and reclaims source space via
|
||||
/// <see cref="RemoveSource"/>.
|
||||
/// </summary>
|
||||
public interface IPhotoProvider
|
||||
{
|
||||
/// <summary>
|
||||
/// Returns absolute local filesystem paths of every photo to import. For MTP devices
|
||||
/// the implementer is responsible for any staging copy to a temp path; the pipeline
|
||||
/// only ever sees local paths it can open.
|
||||
/// </summary>
|
||||
List<string> EnumeratePhotos();
|
||||
|
||||
/// <summary>
|
||||
/// Reclaims the source copy of a file after it has been verified at the destination.
|
||||
/// For mass storage this is File.Delete (or File.Move, when the importer chooses to move
|
||||
/// rather than convert). For MTP it deletes the object via the shell namespace, or — for
|
||||
/// phones with delete-from-phone off — only the staged temp copy.
|
||||
/// Must be safe to call exactly once per path returned by <see cref="EnumeratePhotos"/>,
|
||||
/// and safe to call even when the import was skipped as a duplicate (the staged temp copy
|
||||
/// should always be reclaimed).
|
||||
///
|
||||
/// Returns true if the device-side original was actually removed; false if it was kept
|
||||
/// (delete-from-phone off) or the removal failed (MTP driver didn't honor the delete verb).
|
||||
/// Callers use the return value to log accurately rather than claiming "removed" on failure.
|
||||
/// </summary>
|
||||
bool RemoveSource(string path);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
using System;
|
||||
using System.IO;
|
||||
using AutoIngest.Core;
|
||||
using ImageMagick;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Converts any image format to ecommerce-optimized progressive JPG.
|
||||
/// Delegates all format detection to Magick.NET (RAW, HEIC, SVG, standard raster).
|
||||
/// </summary>
|
||||
public class ImageConverter
|
||||
{
|
||||
static ImageConverter()
|
||||
{
|
||||
MagickNET.SetTempDirectory(Path.GetTempPath());
|
||||
}
|
||||
|
||||
public void ConvertToJpg(string inputPath, string outputPath,
|
||||
int quality = 92, bool autoOrient = true, bool stripExif = true,
|
||||
BrandingConfig? branding = null)
|
||||
{
|
||||
using var image = new MagickImage(inputPath);
|
||||
|
||||
// No dimension limit: images are converted at their native resolution.
|
||||
// Older cameras (≤20MP) produce reasonably sized output and we don't want
|
||||
// to throw away detail by downscaling.
|
||||
|
||||
// Honor the EXIF orientation tag by actually rotating/flipping the pixels. Phones and
|
||||
// some cameras set the tag without rotating, so the image appears sideways in any
|
||||
// viewer that doesn't read the tag (many browser upload previews don't). Done before
|
||||
// stripping metadata so the corrected orientation is baked in.
|
||||
if (autoOrient)
|
||||
image.AutoOrient();
|
||||
|
||||
image.ColorSpace = ColorSpace.sRGB;
|
||||
image.Alpha(AlphaOption.Off);
|
||||
|
||||
// Branding (visible watermarks) is burned in here — it's pixels, not metadata, so it
|
||||
// survives the Strip() below and any downstream re-upload. No-op when branding is off.
|
||||
BrandingRenderer.Apply(image, branding);
|
||||
|
||||
// Strip EXIF/XMP/etc. EXIF can carry GPS coordinates — a phone photo embedded in an
|
||||
// eBay listing can leak the seller's location. Stripping after AutoOrient means the
|
||||
// orientation is already applied to pixels and the tag is no longer needed.
|
||||
if (stripExif)
|
||||
image.Strip();
|
||||
|
||||
// EXIF copyright is written AFTER strip so the seller's attribution is the only
|
||||
// metadata that survives — camera model, GPS, software tags are all gone, and only
|
||||
// the Artist/Copyright/Description the user opted into remain. This realizes the
|
||||
// "strip everything then insert branding metadata" behavior.
|
||||
WriteCopyrightFields(image, branding);
|
||||
|
||||
image.Format = MagickFormat.Jpeg;
|
||||
image.Quality = (uint)quality;
|
||||
image.Settings.SetDefine("jpeg:optimize-coding", "true");
|
||||
image.Settings.SetDefine("jpeg:progressive", "true");
|
||||
image.Settings.Interlace = Interlace.Line;
|
||||
|
||||
image.Write(outputPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Embeds EXIF Artist/Copyright/ImageDescription from <paramref name="branding"/> when the
|
||||
/// user has enabled it. Called after Strip() so these are the only metadata that remain.
|
||||
/// Skips empty values so we never write blank tags.
|
||||
/// </summary>
|
||||
static void WriteCopyrightFields(MagickImage image, BrandingConfig? branding)
|
||||
{
|
||||
if (branding == null || !branding.Enabled || !branding.ExifCopyrightEnabled) return;
|
||||
|
||||
try
|
||||
{
|
||||
// ExifProfile must exist on the image for SetAttribute("exif:...") to land at write
|
||||
// time when we've just stripped. Create an empty profile if there isn't one.
|
||||
if (image.GetExifProfile() == null)
|
||||
image.SetProfile(new ExifProfile());
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(branding.ExifArtist))
|
||||
image.SetAttribute("exif:Artist", branding.ExifArtist.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(branding.ExifCopyright))
|
||||
image.SetAttribute("exif:Copyright", branding.ExifCopyright.Trim());
|
||||
if (!string.IsNullOrWhiteSpace(branding.ExifDescription))
|
||||
image.SetAttribute("exif:ImageDescription", branding.ExifDescription.Trim());
|
||||
}
|
||||
catch (MagickException)
|
||||
{
|
||||
// Profile/attribute write failed — non-fatal; the image still writes without the
|
||||
// copyright tags. The pixels and watermark are unaffected.
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanRead(string path)
|
||||
{
|
||||
// One MagickException catch covers corrupt/delegate/unknown-format errors (all derive
|
||||
// from it). No bare catch — non-Magick failures (OOM, IO) should surface, not mask as
|
||||
// "unreadable image."
|
||||
try
|
||||
{
|
||||
_ = new MagickImageInfo(path);
|
||||
return true;
|
||||
}
|
||||
catch (MagickException) { return false; }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,341 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using AutoIngest.Core;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Result the UI returns when asked about an unknown device.
|
||||
/// </summary>
|
||||
public enum UnknownDeviceResolution
|
||||
{
|
||||
/// <summary>Register the device and import now (and from now on).</summary>
|
||||
RegisterAndImport,
|
||||
/// <summary>Import this one time only; do not persist to the registry.</summary>
|
||||
ImportOnce,
|
||||
/// <summary>Skip this device for the rest of the session.</summary>
|
||||
Ignore,
|
||||
/// <summary>User dismissed the prompt; treat like Ignore for this tick but ask again next session.</summary>
|
||||
Dismissed
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owns the polling thread. Every tick it asks each registered <see cref="IDeviceSource"/>
|
||||
/// for connected devices and, for each device that passes the <see cref="DeviceRegistry"/>
|
||||
/// filter (or that the user approves via <see cref="ResolveUnknownDevice"/>), runs it through
|
||||
/// the shared <see cref="DeviceImporter"/>. This is the direct successor to SDCardMonitor's
|
||||
/// old RunLoop: same 4-second cadence, same single-import-at-a-time lock, but now source-
|
||||
/// agnostic and gated by the opt-in registry.
|
||||
/// </summary>
|
||||
public class ImportCoordinator : IDisposable
|
||||
{
|
||||
const int CHECK_INTERVAL_MS = 4000;
|
||||
|
||||
// Retention sweep cadence: once on startup, then at most this often while running.
|
||||
static readonly TimeSpan RETENTION_INTERVAL = TimeSpan.FromHours(24);
|
||||
// Delay the very first sweep so it doesn't race startup imports or disk activity.
|
||||
static readonly TimeSpan RETENTION_STARTUP_DELAY = TimeSpan.FromSeconds(30);
|
||||
|
||||
readonly DeviceImporter _importer;
|
||||
readonly DeviceRegistry _registry;
|
||||
readonly List<IDeviceSource> _sources;
|
||||
readonly AppConfig _config;
|
||||
|
||||
Thread? _thread;
|
||||
volatile bool _running;
|
||||
volatile bool _importing;
|
||||
|
||||
// Devices we've already handled this session, keyed by stable DeviceIdentity.Id so the
|
||||
// same physical card isn't re-imported when it stays inserted, and survives letter changes.
|
||||
readonly HashSet<string> _handledThisSession = new();
|
||||
// Devices explicitly ignored by the user this session (not persisted).
|
||||
readonly HashSet<string> _ignoredThisSession = new();
|
||||
readonly object _lock = new();
|
||||
|
||||
// Retention scheduling. _lastSweep starts at MinValue so the first loop tick after the
|
||||
// startup delay satisfies the interval check and fires one sweep; thereafter it's gated
|
||||
// by the 24h interval. NOTE: the on-demand sweep (RunRetentionSweepNow, from the Settings
|
||||
// UI) does NOT coordinate with this via a lock; a double-run is harmless because Sweep is
|
||||
// idempotent (the second pass finds the already-recycled folder gone). If Sweep ever
|
||||
// becomes non-idempotent, add a dedicated sweep lock here.
|
||||
DateTime _startTime = DateTime.UtcNow;
|
||||
DateTime _lastSweep = DateTime.MinValue;
|
||||
bool _accessTimeTrackingEnabled;
|
||||
|
||||
public ImportCoordinator(DeviceImporter importer, DeviceRegistry registry, AppConfig config, IEnumerable<IDeviceSource> sources)
|
||||
{
|
||||
_importer = importer;
|
||||
_registry = registry;
|
||||
_config = config;
|
||||
_sources = sources.ToList();
|
||||
|
||||
// Forward the importer's events through the coordinator's own, so the UI wires a
|
||||
// single set of handlers against the coordinator regardless of which source raised.
|
||||
// (ImportDone is owned and raised by the coordinator itself, based on Import's result.)
|
||||
_importer.DriveFound += (a, b) => DriveFound?.Invoke(a, b);
|
||||
_importer.ImportProgress += (a, b, c) => ImportProgress?.Invoke(a, b, c);
|
||||
_importer.ImportError += m => ImportError?.Invoke(m);
|
||||
_importer.LogMessage += (m, c) => LogMessage?.Invoke(m, c);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Raised when a connected device isn't in the registry. The UI must resolve it
|
||||
/// synchronously and return the user's choice. Raised on the coordinator thread, so the
|
||||
/// handler must marshal to its own UI thread (e.g. Form.Invoke) before blocking on input.
|
||||
/// </summary>
|
||||
public event Func<DeviceIdentity, UnknownDeviceResolution>? ResolveUnknownDevice;
|
||||
|
||||
// The coordinator surfaces its own event fields and forwards the importer's raises through
|
||||
// them. (C# events can only be invoked from the declaring type, so we can't simply re-expose
|
||||
// the importer's events via add/remove that tries to invoke them.)
|
||||
public event Action<string, string>? DriveFound;
|
||||
public event Action<int, int, string>? ImportProgress;
|
||||
public event Action<int, int, string, string>? ImportDone;
|
||||
public event Action<string>? ImportError;
|
||||
public event Action<string, string>? LogMessage;
|
||||
|
||||
/// <summary>Raised after a retention sweep with (foldersRecycled, bytesReclaimed).</summary>
|
||||
public event Action<int, long>? RetentionSweepDone;
|
||||
|
||||
public void Start()
|
||||
{
|
||||
_running = true;
|
||||
_startTime = DateTime.UtcNow;
|
||||
// Probe access-time tracking once at startup (cheap, used to pick the age signal).
|
||||
// Failures default to false, which means retention falls back to LastWriteTime.
|
||||
try { _accessTimeTrackingEnabled = AccessTimeTracker.IsEnabled(); }
|
||||
catch { _accessTimeTrackingEnabled = false; }
|
||||
|
||||
_thread = new Thread(RunLoop) { IsBackground = true };
|
||||
_thread.Start();
|
||||
}
|
||||
|
||||
public void Stop()
|
||||
{
|
||||
_running = false;
|
||||
_thread?.Join(5000);
|
||||
}
|
||||
|
||||
/// <summary>Clears both the handled-this-session and ignored-this-session sets.</summary>
|
||||
public void ResetMemory()
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
_handledThisSession.Clear();
|
||||
_ignoredThisSession.Clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>The set of currently-connected devices across all sources (for the UI's
|
||||
/// "add currently connected device" feature).</summary>
|
||||
public List<DeviceIdentity> GetCurrentlyConnected()
|
||||
{
|
||||
var list = new List<DeviceIdentity>();
|
||||
foreach (var src in _sources)
|
||||
{
|
||||
try { list.AddRange(src.GetConnectedDevices()); }
|
||||
catch (Exception ex) { LogMessage?.Invoke($"{src.SourceName} scan failed: {ex.Message}", "#f85149"); }
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Runs a retention sweep immediately, ignoring the 24h throttle. Called from the
|
||||
/// Settings dialog's "Run sweep now" button. Runs synchronously on the caller's thread
|
||||
/// (the UI thread) — sweeps are I/O-bound but fast for typical folder counts, and doing
|
||||
/// it synchronously lets the UI report the result. Returns (recycled, bytesReclaimed).
|
||||
/// </summary>
|
||||
public (int recycled, long bytesReclaimed) RunRetentionSweepNow()
|
||||
{
|
||||
return DoRetentionSweep(forceLog: true);
|
||||
}
|
||||
|
||||
/// <summary>Whether NTFS access-time tracking is on (affects which timestamp retention
|
||||
/// uses). Probed once at Start; safe to re-query from the UI.</summary>
|
||||
public bool IsAccessTimeTrackingEnabled => _accessTimeTrackingEnabled;
|
||||
|
||||
(int recycled, long bytesReclaimed) DoRetentionSweep(bool forceLog)
|
||||
{
|
||||
if (!_config.RetentionEnabled)
|
||||
{
|
||||
if (forceLog) LogMessage?.Invoke("Retention is off — sweep skipped.", "#8b949e");
|
||||
return (0, 0);
|
||||
}
|
||||
|
||||
var picturesBase = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyPictures);
|
||||
int retentionDays = _config.RetentionDays;
|
||||
bool preferAccess = _accessTimeTrackingEnabled;
|
||||
|
||||
if (forceLog)
|
||||
LogMessage?.Invoke(
|
||||
$"Retention sweep starting (>{retentionDays} days, {(preferAccess ? "access time" : "import date")}): {picturesBase}",
|
||||
"#d29922");
|
||||
|
||||
try
|
||||
{
|
||||
var (recycled, reclaimed) = FolderRetentionPolicy.Sweep(
|
||||
picturesBase, retentionDays, preferAccess,
|
||||
(msg, color) => LogMessage?.Invoke(msg, color));
|
||||
|
||||
RetentionSweepDone?.Invoke(recycled, reclaimed);
|
||||
return (recycled, reclaimed);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke($"Retention sweep failed: {ex.Message}", "#f85149");
|
||||
return (0, 0);
|
||||
}
|
||||
}
|
||||
|
||||
void RunLoop()
|
||||
{
|
||||
var photosBase = System.Environment.GetFolderPath(System.Environment.SpecialFolder.MyPictures);
|
||||
LogMessage?.Invoke("Auto-import monitor active. Insert a registered SD card, USB drive, or phone.", "#58a6ff");
|
||||
LogMessage?.Invoke($"Destination: {photosBase}\\<today>\\", "#58a6ff");
|
||||
|
||||
while (_running)
|
||||
{
|
||||
try { PollOnce(); }
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke(ex.Message, "#f85149");
|
||||
ImportError?.Invoke(ex.Message);
|
||||
_importing = false;
|
||||
}
|
||||
|
||||
TryScheduleRetention();
|
||||
|
||||
Thread.Sleep(CHECK_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One polling tick: snapshot connected devices, handle the first actionable one, prune
|
||||
/// disconnected devices from session memory. All under _lock so ResetMemory (UI thread)
|
||||
/// can't race with an in-flight tick. Flattened from the original nested loop so the
|
||||
/// per-device decision lives in its own single-level method.
|
||||
/// </summary>
|
||||
void PollOnce()
|
||||
{
|
||||
var connected = GetCurrentlyConnected();
|
||||
var connectedIds = new HashSet<string>(connected.Select(d => d.Id));
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (_importing) return;
|
||||
|
||||
foreach (var device in connected)
|
||||
{
|
||||
if (_handledThisSession.Contains(device.Id)) continue;
|
||||
if (_ignoredThisSession.Contains(device.Id)) continue;
|
||||
if (HandleDevice(device)) break; // at most one import per tick
|
||||
}
|
||||
|
||||
// Forget handled devices that have since disconnected so they re-import next plug.
|
||||
// Ignored devices stay ignored for the session — that's a user choice.
|
||||
_handledThisSession.ExceptWith(
|
||||
_handledThisSession.Where(id => !connectedIds.Contains(id)).ToList());
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves one connected device: import it if registered, else ask the UI and dispatch on
|
||||
/// its answer. Returns true if an import was kicked off (caller stops iterating for this
|
||||
/// tick — only one import at a time). Single-level: no nesting beyond the switch.
|
||||
/// </summary>
|
||||
bool HandleDevice(DeviceIdentity device)
|
||||
{
|
||||
// Registered → import directly, no prompt.
|
||||
if (_registry.IsRegistered(device.Id))
|
||||
{
|
||||
_handledThisSession.Add(device.Id);
|
||||
_importing = true;
|
||||
ImportDevice(device);
|
||||
return true;
|
||||
}
|
||||
|
||||
// Unregistered → ask the UI synchronously (it marshals to the UI thread). If no handler
|
||||
// is wired, default to Ignore so a misconfigured host never auto-imports unknowns.
|
||||
var resolution = ResolveUnknownDevice?.Invoke(device) ?? UnknownDeviceResolution.Ignore;
|
||||
switch (resolution)
|
||||
{
|
||||
case UnknownDeviceResolution.RegisterAndImport:
|
||||
_registry.Register(device.Id, device.DisplayName, device.Kind);
|
||||
_registry.Save();
|
||||
_handledThisSession.Add(device.Id);
|
||||
_importing = true;
|
||||
ImportDevice(device);
|
||||
LogMessage?.Invoke($"Registered '{device.DisplayName}' ({device.Kind}).", "#3fb950");
|
||||
return true;
|
||||
|
||||
case UnknownDeviceResolution.ImportOnce:
|
||||
_handledThisSession.Add(device.Id);
|
||||
_importing = true;
|
||||
ImportDevice(device);
|
||||
return true;
|
||||
|
||||
case UnknownDeviceResolution.Ignore:
|
||||
case UnknownDeviceResolution.Dismissed:
|
||||
default:
|
||||
_ignoredThisSession.Add(device.Id);
|
||||
LogMessage?.Invoke($"Ignoring '{device.DisplayName}' for this session.", "#8b949e");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Retention sweep scheduler: one pass shortly after startup, then at most every 24h.
|
||||
/// Runs the sweep off the import lock on a background thread so a slow disk sweep never
|
||||
/// holds up the 4s import poll. The on-demand UI path uses RunRetentionSweepNow() instead.
|
||||
/// A theoretical double-run is harmless — Sweep is idempotent (a just-recycled folder is
|
||||
/// not returned by the next EnumerateDatedFolders).
|
||||
/// </summary>
|
||||
void TryScheduleRetention()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!_config.RetentionEnabled) return;
|
||||
var now = DateTime.UtcNow;
|
||||
bool startupElapsed = (now - _startTime) >= RETENTION_STARTUP_DELAY;
|
||||
bool intervalElapsed = (now - _lastSweep) >= RETENTION_INTERVAL;
|
||||
if (!startupElapsed || !intervalElapsed || _importing) return;
|
||||
|
||||
_lastSweep = now;
|
||||
System.Threading.Tasks.Task.Run(() => DoRetentionSweep(forceLog: false));
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke($"Retention scheduler error: {ex.Message}", "#f85149");
|
||||
}
|
||||
}
|
||||
|
||||
void ImportDevice(DeviceIdentity device)
|
||||
{
|
||||
try
|
||||
{
|
||||
var result = _importer.Import(device);
|
||||
if (result.total > 0)
|
||||
ImportDone?.Invoke(result.moved, result.converted, result.sizeStr, result.destFolder);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
LogMessage?.Invoke($"Import failed for {device.DisplayName}: {ex.Message}", "#f85149");
|
||||
ImportError?.Invoke(ex.Message);
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release any per-import resources the provider holds (notably the MTP staging
|
||||
// temp directory, which must not leak across the 4s poll ticks).
|
||||
if (device.Photos is IDisposable disposable) disposable.Dispose();
|
||||
}
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Stop();
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,525 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Phone / portable-device source. Detects MTP/PTP devices (iPhones, Android phones, and
|
||||
/// modern cameras that don't expose a drive letter) via the Windows Shell namespace — the
|
||||
/// same "This PC" tree Explorer shows portable devices under. This is the approach flagged
|
||||
/// in BLOG.md as the planned replacement for the Python version's MTP support.
|
||||
///
|
||||
/// Shell COM is happiest on an STA thread, so enumeration is marshaled onto a dedicated
|
||||
/// STA worker. Every COM touch is wrapped: a locked/untrusted phone, a dead battery, or a
|
||||
/// flaky driver must never crash the monitor — failures degrade to "no devices seen this
|
||||
/// tick" and a log line. Phones are strictly best-effort; the SD/USB path is unaffected.
|
||||
///
|
||||
/// We use late binding (`dynamic` against `Shell.Application`) to avoid taking on an Interop
|
||||
/// assembly for a single COM object — the project's zero-extra-dependency posture is preserved.
|
||||
/// </summary>
|
||||
public class MtpDeviceSource : IDeviceSource
|
||||
{
|
||||
const int SSF_DRIVES = 0x11; // ssfDRIVES — the "This PC" / "My Computer" namespace
|
||||
|
||||
// Bounded operations (MISRA spirit: no magic numbers). Timeouts are generous because MTP
|
||||
// drivers are slow; the recursion/poll bounds keep a pathological tree from running away.
|
||||
const int MAX_MTP_DEPTH = 3; // how deep into the DCIM tree to walk for photos
|
||||
const int STAGE_POLL_ATTEMPTS = 60; // polls for a CopyHere-landed file to appear
|
||||
const int STAGE_POLL_DELAY_MS = 100; // delay between those polls (6s total budget)
|
||||
const int COPYHERE_NO_UI_FLAG = 16; // FOF_NOCONFIRMATION | FOF_NOERRORUI semantics
|
||||
|
||||
// Live flag accessor: phones delete their originals after verified import when this is on.
|
||||
// Read per-import (not snapshotted) so the tray toggle takes effect on the next device.
|
||||
readonly Func<bool> _shouldDeleteFromPhone;
|
||||
|
||||
public MtpDeviceSource(Func<bool>? shouldDeleteFromPhone = null)
|
||||
{
|
||||
_shouldDeleteFromPhone = shouldDeleteFromPhone ?? (() => false);
|
||||
}
|
||||
|
||||
public string SourceName => "Phone (MTP)";
|
||||
|
||||
public IEnumerable<DeviceIdentity> GetConnectedDevices()
|
||||
{
|
||||
List<DeviceIdentity> results = new();
|
||||
try
|
||||
{
|
||||
// Marshal onto an STA thread so Shell.Application behaves. Run synchronously:
|
||||
// GetConnectedDevices is itself polled on a background thread, and a 4s tick is
|
||||
// far longer than enumeration takes.
|
||||
Exception? error = null;
|
||||
var sta = new Thread(() =>
|
||||
{
|
||||
try { results = EnumerateOnSta(); }
|
||||
catch (Exception ex) { error = ex; }
|
||||
})
|
||||
{ IsBackground = true };
|
||||
sta.SetApartmentState(ApartmentState.STA);
|
||||
sta.Start();
|
||||
sta.Join(TimeSpan.FromSeconds(8));
|
||||
|
||||
if (error != null)
|
||||
{
|
||||
// Logged by the coordinator via the normal LogMessage channel; here we just
|
||||
// fall through with whatever (possibly partial) results we got.
|
||||
Debug.WriteLine($"MTP enumeration failed: {error.Message}");
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"MTP source thread error: {ex.Message}");
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
/// <summary>Walks "This PC" for items that look like portable devices (phones/cameras).</summary>
|
||||
List<DeviceIdentity> EnumerateOnSta()
|
||||
{
|
||||
var results = new List<DeviceIdentity>();
|
||||
Type? shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||
if (shellType == null) return results;
|
||||
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
try
|
||||
{
|
||||
dynamic thisPc = shell.NameSpace(SSF_DRIVES);
|
||||
if (thisPc == null) return results;
|
||||
|
||||
// Items() on "This PC" returns drives (C:\, D:\) AND portable devices (phones).
|
||||
// Portable devices have a Path of the form "::{guid}" or a name with no drive
|
||||
// letter and are flagged as folders. Filter out anything with a colon (drive
|
||||
// letter) — those belong to the mass-storage source.
|
||||
foreach (var item in thisPc.Items())
|
||||
{
|
||||
DeviceIdentity? identity = TryIdentifyPortableDevice(item);
|
||||
if (identity != null) results.Add(identity);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// Release the COM objects. Late-bound dynamics hold RCWs; Marshal.ReleaseComObject
|
||||
// ensures we don't keep the shell alive between polls.
|
||||
try { if (shell != null) System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); }
|
||||
catch { }
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
DeviceIdentity? TryIdentifyPortableDevice(dynamic item)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Portable-device entries are folders whose path is a "::"-prefixed PIDL string
|
||||
// (not a drive letter like "C:\"). Skip anything that looks like a drive.
|
||||
string? path = SafeString(() => item.Path);
|
||||
string? name = SafeString(() => item.Name);
|
||||
|
||||
if (string.IsNullOrEmpty(path) || string.IsNullOrEmpty(name)) return null;
|
||||
if (path.Length >= 2 && path[1] == ':') return null; // "X:\" — a drive
|
||||
if (!SafeBool(() => item.IsFolder)) return null;
|
||||
|
||||
// "Internal Storage", "Card" etc. are sub-items; we only want the device root,
|
||||
// which carries the friendly name (e.g. "Pixel 7", "iPhone").
|
||||
if (IsLikelyStorageSubfolder(name)) return null;
|
||||
|
||||
// Open the device namespace to read its serial and to use as the photo provider.
|
||||
dynamic? deviceFolder;
|
||||
try { deviceFolder = item.GetFolder; }
|
||||
catch { return null; }
|
||||
if (deviceFolder == null) return null;
|
||||
|
||||
string serial = ReadDeviceSerial(deviceFolder, name);
|
||||
|
||||
// Build a stable id. Prefer the PTP/MTP serial; fall back to the device name if
|
||||
// the driver doesn't expose one (rare; flagged so the UI can warn).
|
||||
string id = !string.IsNullOrEmpty(serial)
|
||||
? $"MTP:{serial}:{name.ToUpperInvariant()}"
|
||||
: $"WEAK:MTP:{name.ToUpperInvariant()}:{TryGetFreeSpace(deviceFolder)}";
|
||||
|
||||
string kind = !string.IsNullOrEmpty(serial) ? "Phone" : "Phone-weak";
|
||||
|
||||
return new DeviceIdentity
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = name,
|
||||
Kind = kind,
|
||||
Photos = new MtpPhotoProvider(deviceFolder, name, _shouldDeleteFromPhone)
|
||||
};
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"MTP device identify failed: {ex.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a serial number / unique id for the device. MTP devices expose properties via
|
||||
/// ExtendedProperty; the canonical key for the device serial is the PTP DeviceSerialNumber,
|
||||
/// which on Windows surfaces under the property index. We try a small set of known keys
|
||||
/// and accept the first non-empty value.
|
||||
/// </summary>
|
||||
static string ReadDeviceSerial(dynamic deviceFolder, string deviceName)
|
||||
{
|
||||
// ExtendedProperty keys vary by driver. Try the common ones for device identity.
|
||||
string[] keys =
|
||||
{
|
||||
"System.Devices.Aep.DeviceAddress",
|
||||
"System.Devices.DeviceInstanceId",
|
||||
"System.ItemNameDisplay"
|
||||
};
|
||||
foreach (var key in keys)
|
||||
{
|
||||
try
|
||||
{
|
||||
object? value = deviceFolder.ExtendedProperty(key);
|
||||
string? s = value?.ToString();
|
||||
if (!string.IsNullOrWhiteSpace(s) && !string.Equals(s, deviceName, StringComparison.OrdinalIgnoreCase))
|
||||
return s!;
|
||||
}
|
||||
catch { /* next key */ }
|
||||
}
|
||||
|
||||
// No property worked — synthesize a pseudo-serial from the device's self-path, which
|
||||
// contains the device interface GUID and is stable for the lifetime of the pairing.
|
||||
try
|
||||
{
|
||||
string? self = SafeString(() => deviceFolder.Self?.Path);
|
||||
if (!string.IsNullOrEmpty(self) && self.Length > 2 && self[1] != ':')
|
||||
return self!;
|
||||
}
|
||||
catch { }
|
||||
return "";
|
||||
}
|
||||
|
||||
static bool IsLikelyStorageSubfolder(string? name)
|
||||
{
|
||||
if (string.IsNullOrEmpty(name)) return false;
|
||||
// Sub-storage entries phones expose: "Internal Storage", "Phone", "Card", "SD card".
|
||||
string n = name.ToUpperInvariant();
|
||||
return n.Contains("STORAGE") || n == "PHONE" || n == "CARD" || n.Contains("SD CARD");
|
||||
}
|
||||
|
||||
static long TryGetFreeSpace(dynamic deviceFolder)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Size is unreliable over MTP and is only used as a weak-id fallback ingredient.
|
||||
var items = deviceFolder.Items();
|
||||
long total = 0;
|
||||
foreach (var it in items)
|
||||
{
|
||||
try { total += SafeLong(() => it.Size); } catch { }
|
||||
}
|
||||
return total;
|
||||
}
|
||||
catch { return 0; }
|
||||
}
|
||||
|
||||
static string? SafeString(Func<string?> f) { try { return f(); } catch { return null; } }
|
||||
static bool SafeBool(Func<bool> f) { try { return f(); } catch { return false; } }
|
||||
static long SafeLong(Func<long> f) { try { return f(); } catch { return 0; } }
|
||||
|
||||
/// <summary>
|
||||
/// Photo provider for an MTP device. Enumerates image-bearing folders (DCIM and the
|
||||
/// like) via the shell namespace, stages each file to a temp path on disk (MTP objects
|
||||
/// aren't openable via File.OpenRead), and on removal invokes the shell "delete" verb to
|
||||
/// remove the source object from the phone.
|
||||
/// </summary>
|
||||
sealed class MtpPhotoProvider : IPhotoProvider, IDisposable
|
||||
{
|
||||
readonly object _deviceFolder; // held as RCW; dynamic-dispatched on the STA thread
|
||||
readonly string _deviceName;
|
||||
readonly string _tempDir;
|
||||
readonly Func<bool> _shouldDeleteFromPhone;
|
||||
// Maps staged temp path -> phone-side shell path, so RemoveSource can resolve the
|
||||
// original to delete when the user has opted into delete-from-phone. Populated during
|
||||
// EnumeratePhotos and read during RemoveSource.
|
||||
readonly Dictionary<string, string> _stagedToSource = new(StringComparer.OrdinalIgnoreCase);
|
||||
int _stagedCounter; // disambiguates same-named originals into unique staged filenames
|
||||
|
||||
public MtpPhotoProvider(dynamic deviceFolder, string deviceName, Func<bool> shouldDeleteFromPhone)
|
||||
{
|
||||
_deviceFolder = deviceFolder;
|
||||
_deviceName = deviceName;
|
||||
_shouldDeleteFromPhone = shouldDeleteFromPhone;
|
||||
_tempDir = Path.Combine(Path.GetTempPath(), "AutoIngest_Mtp", Guid.NewGuid().ToString("N"));
|
||||
}
|
||||
|
||||
public List<string> EnumeratePhotos()
|
||||
{
|
||||
var staged = new List<string>();
|
||||
|
||||
// Shell enumeration must happen on STA. Build and run the work synchronously.
|
||||
Exception? error = null;
|
||||
var sta = new Thread(() =>
|
||||
{
|
||||
try { staged = EnumerateAndStageOnSta(); }
|
||||
catch (Exception ex) { error = ex; }
|
||||
}) { IsBackground = true };
|
||||
sta.SetApartmentState(ApartmentState.STA);
|
||||
sta.Start();
|
||||
sta.Join(TimeSpan.FromMinutes(2));
|
||||
|
||||
if (error != null)
|
||||
Debug.WriteLine($"MTP enumerate failed for {_deviceName}: {error.Message}");
|
||||
|
||||
return staged;
|
||||
}
|
||||
|
||||
List<string> EnumerateAndStageOnSta()
|
||||
{
|
||||
var results = new List<string>();
|
||||
dynamic folder = _deviceFolder;
|
||||
Directory.CreateDirectory(_tempDir);
|
||||
|
||||
// Photo folders phones expose. DCIM is the PTP standard; iOS also uses DCIM.
|
||||
string[] candidates = { "DCIM", "Pictures", "Internal Storage", "Card" };
|
||||
|
||||
foreach (var sub in EnumerateItems(folder))
|
||||
{
|
||||
string? name = SafeStringDyn(() => sub.Name);
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
bool photoFolder = candidates.Any(c =>
|
||||
string.Equals(c, name, StringComparison.OrdinalIgnoreCase));
|
||||
if (!photoFolder)
|
||||
{
|
||||
// Some phones nest a brand folder under DCIM and store photos directly in
|
||||
// a sub-tree. Also accept any folder that itself contains images.
|
||||
photoFolder = FolderContainsImages(sub);
|
||||
}
|
||||
if (!photoFolder) continue;
|
||||
|
||||
foreach (var photoPath in EnumerateImageFiles(sub, depth: 0))
|
||||
{
|
||||
string staged = StageFile(photoPath);
|
||||
if (staged.Length > 0)
|
||||
{
|
||||
_stagedToSource[staged] = photoPath;
|
||||
results.Add(staged);
|
||||
}
|
||||
}
|
||||
}
|
||||
return results;
|
||||
}
|
||||
|
||||
// Enumerate image files up to 3 levels deep inside a shell folder, returning the
|
||||
// folder+file dynamic pairs as opaque paths the caller stages. We return the shell
|
||||
// "file" object path strings the caller can re-resolve.
|
||||
IEnumerable<string> EnumerateImageFiles(dynamic folder, int depth)
|
||||
{
|
||||
if (depth > MAX_MTP_DEPTH) yield break;
|
||||
System.Collections.IEnumerable items;
|
||||
try { items = folder.Items(); }
|
||||
catch { yield break; }
|
||||
|
||||
foreach (var raw in items)
|
||||
{
|
||||
dynamic item = raw; // late-bind so .IsFolder/.Name/.Path resolve via the COM RCW
|
||||
bool isFolder = SafeBoolDyn(() => item.IsFolder);
|
||||
string? name = SafeStringDyn(() => item.Name);
|
||||
if (string.IsNullOrEmpty(name)) continue;
|
||||
|
||||
if (isFolder)
|
||||
{
|
||||
// Recurse (skip system/junk folder names).
|
||||
string upper = name.ToUpperInvariant();
|
||||
if (upper is "THM" or "MISC") continue;
|
||||
dynamic? sub;
|
||||
try { sub = item.GetFolder; } catch { continue; }
|
||||
if (sub != null)
|
||||
foreach (var p in EnumerateImageFiles(sub, depth + 1)) yield return p;
|
||||
}
|
||||
else
|
||||
{
|
||||
string ext = Path.GetExtension(name);
|
||||
if (DeviceImporter.IsImageFile(ext))
|
||||
yield return ((string?)SafeStringDyn(() => item.Path)) ?? name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
bool FolderContainsImages(dynamic folder)
|
||||
{
|
||||
try
|
||||
{
|
||||
foreach (var raw in folder.Items())
|
||||
{
|
||||
dynamic item = raw;
|
||||
if (!SafeBoolDyn(() => item.IsFolder))
|
||||
{
|
||||
string? name = SafeStringDyn(() => item.Name);
|
||||
if (!string.IsNullOrEmpty(name) &&
|
||||
DeviceImporter.IsImageFile(Path.GetExtension(name)))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
catch { }
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Copies a shell-referenced file to local temp and returns the staged path. MTP
|
||||
/// objects must be invoked via their verb ("copy"/"invoke") or read via the shell's
|
||||
/// NameSpace copy; the simplest reliable path is Folder.CopyHere of the item into a
|
||||
/// temp dir, which Windows handles as an MTP transfer.
|
||||
/// </summary>
|
||||
string StageFile(string shellPath)
|
||||
{
|
||||
try
|
||||
{
|
||||
// Use a unique staged name so two same-named originals (e.g. IMG_0001.JPG in
|
||||
// different DCIM subfolders — common on iPhones that roll over) don't collide
|
||||
// and silently drop one. A counter prefix preserves sortability and the
|
||||
// original extension for format detection downstream.
|
||||
string baseName = Path.GetFileName(shellPath);
|
||||
string stagedName = $"{_stagedCounter:D4}_{baseName}";
|
||||
_stagedCounter++;
|
||||
string dest = Path.Combine(_tempDir, stagedName);
|
||||
|
||||
// Re-resolve via Shell to copy. Using CopyHere from the device's parent folder
|
||||
// is the canonical MTP download primitive.
|
||||
Type? shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||
if (shellType == null) return "";
|
||||
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
try
|
||||
{
|
||||
// Namespace(shellPath) on a file path returns the containing folder.
|
||||
dynamic? parent = shell.Namespace(Path.GetDirectoryName(shellPath));
|
||||
if (parent == null) return "";
|
||||
parent.CopyHere(shellPath, COPYHERE_NO_UI_FLAG);
|
||||
// CopyHere lands the file under the ORIGINAL name in dest's directory; we
|
||||
// then rename to the unique staged name. Poll for the original to arrive.
|
||||
string landedUnderOrigName = Path.Combine(_tempDir, baseName);
|
||||
for (int i = 0; i < STAGE_POLL_ATTEMPTS && !File.Exists(landedUnderOrigName); i++) Thread.Sleep(STAGE_POLL_DELAY_MS);
|
||||
if (!File.Exists(landedUnderOrigName)) return "";
|
||||
if (landedUnderOrigName != dest)
|
||||
{
|
||||
File.Move(landedUnderOrigName, dest);
|
||||
}
|
||||
return File.Exists(dest) ? dest : "";
|
||||
}
|
||||
finally
|
||||
{
|
||||
try { System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); } catch { }
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Debug.WriteLine($"MTP stage failed for {shellPath}: {ex.Message}");
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
static List<dynamic> EnumerateItems(dynamic folder)
|
||||
{
|
||||
var list = new List<dynamic>();
|
||||
try
|
||||
{
|
||||
foreach (var item in folder.Items()) list.Add(item);
|
||||
}
|
||||
catch { }
|
||||
return list;
|
||||
}
|
||||
|
||||
public bool RemoveSource(string path)
|
||||
{
|
||||
// Always clean up the staged temp copy regardless of the delete-from-phone flag.
|
||||
try { if (File.Exists(path)) File.Delete(path); }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
|
||||
// Phone-side delete is opt-in (DeleteFromPhoneAfterImport). When off, the phone
|
||||
// original is kept by design — return false so the import log says "src kept"
|
||||
// rather than the misleading "removed".
|
||||
if (!_shouldDeleteFromPhone()) return false;
|
||||
if (!_stagedToSource.TryGetValue(path, out string? shellPath) || string.IsNullOrEmpty(shellPath))
|
||||
return false;
|
||||
|
||||
// Best-effort: shell delete across MTP drivers is inconsistent, so we verify the
|
||||
// object is actually gone afterward and report the true outcome.
|
||||
return DeletePhoneObject(shellPath);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves the phone-side shell item for <paramref name="shellPath"/>, invokes its
|
||||
/// "delete" verb, and verifies the object is gone by re-resolving it. Returns true only
|
||||
/// if the post-delete re-parse no longer finds the item — so callers can log accurately
|
||||
/// rather than claiming success on a silent driver no-op. Runs on an STA thread.
|
||||
/// </summary>
|
||||
bool DeletePhoneObject(string shellPath)
|
||||
{
|
||||
Exception? error = null;
|
||||
bool deleted = false;
|
||||
var sta = new Thread(() =>
|
||||
{
|
||||
Type? shellType = Type.GetTypeFromProgID("Shell.Application");
|
||||
if (shellType == null) return;
|
||||
dynamic shell = Activator.CreateInstance(shellType)!;
|
||||
try
|
||||
{
|
||||
string? dir = Path.GetDirectoryName(shellPath);
|
||||
string? file = Path.GetFileName(shellPath);
|
||||
if (string.IsNullOrEmpty(dir) || string.IsNullOrEmpty(file)) return;
|
||||
|
||||
dynamic? parent = shell.Namespace(dir);
|
||||
if (parent == null) return;
|
||||
dynamic? item = parent.ParseName(file);
|
||||
if (item == null) return;
|
||||
|
||||
// Try the common delete verbs. Drivers/localizations differ; "delete" and
|
||||
// "&Delete" cover most. InvokeVerb throws if the verb doesn't exist.
|
||||
foreach (var verb in new[] { "delete", "&Delete", "Delete" })
|
||||
{
|
||||
try { item.InvokeVerb(verb); break; }
|
||||
catch { /* next verb */ }
|
||||
}
|
||||
|
||||
// Verify: re-parse the name. If it's gone, the delete worked. This is the
|
||||
// only reliable signal across drivers — InvokeVerb's return is meaningless
|
||||
// and a missing verb is a silent no-op.
|
||||
try { deleted = parent.ParseName(file) == null; }
|
||||
catch { deleted = false; }
|
||||
}
|
||||
catch (Exception ex) { error = ex; }
|
||||
finally
|
||||
{
|
||||
try { System.Runtime.InteropServices.Marshal.ReleaseComObject(shell); } catch { }
|
||||
}
|
||||
}) { IsBackground = true };
|
||||
sta.SetApartmentState(ApartmentState.STA);
|
||||
sta.Start();
|
||||
sta.Join(TimeSpan.FromSeconds(15));
|
||||
if (error != null)
|
||||
Debug.WriteLine($"MTP delete failed for {shellPath} on {_deviceName}: {error.Message}");
|
||||
return deleted;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recursively deletes the staging temp directory. Called by the coordinator after an
|
||||
/// import completes (via the provider's Dispose) so repeated phone imports don't leak
|
||||
/// GUID-named folders under %TEMP%\AutoIngest_Mtp\. Best-effort.
|
||||
/// </summary>
|
||||
public void Dispose()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (Directory.Exists(_tempDir))
|
||||
Directory.Delete(_tempDir, recursive: true);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
}
|
||||
|
||||
static string? SafeStringDyn(Func<string?> f) { try { return f(); } catch { return null; } }
|
||||
static bool SafeBoolDyn(Func<bool> f) { try { return f(); } catch { return false; } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Runtime.InteropServices;
|
||||
|
||||
namespace AutoIngest.Engine
|
||||
{
|
||||
/// <summary>
|
||||
/// Mass-storage device source: enumerates SD cards and USB sticks that appear as drive
|
||||
/// letters. Implements <see cref="IDeviceSource"/> so the import coordinator can poll it
|
||||
/// alongside the MTP source. Each connected drive is surfaced as a <see cref="DeviceIdentity"/>
|
||||
/// whose <see cref="DeviceIdentity.Id"/> is built from the volume serial number returned by
|
||||
/// GetVolumeInformationW (stable across reboots and drive-letter reassignment) so the device
|
||||
/// registry recognizes the same physical card no matter which letter it gets next time.
|
||||
///
|
||||
/// The photo-discovery and import logic that used to live here has moved to
|
||||
/// <see cref="DeviceImporter"/>; this class is now only responsible for detection and for
|
||||
/// producing an <see cref="IPhotoProvider"/> per drive.
|
||||
/// </summary>
|
||||
public class SDCardMonitor : IDeviceSource
|
||||
{
|
||||
static readonly HashSet<char> IGNORE_DRIVES = new() { 'C' };
|
||||
|
||||
static readonly string[] SD_KEYWORDS = {
|
||||
"SD", "CARD", "CAMERA", "NO NAME", "UNTITLED",
|
||||
"CANON", "NIKON", "SONY", "FUJI", "GOPRO",
|
||||
"EOS", "LUMIX", "CYBERSHOT", "ALPHA", "PENTAX",
|
||||
"OLYMPUS", "LEICA", "HUAWEI", "SAMSUNG", "PIXEL",
|
||||
"BLACKMAGIC", "DJI", "INSTA360", "MEMORY", "FLASH"
|
||||
};
|
||||
|
||||
static readonly string[] DCIM_PROBES = { "DCIM", "Pictures", "photos", "PICTURES" };
|
||||
|
||||
public string SourceName => "SD/USB";
|
||||
|
||||
public IEnumerable<DeviceIdentity> GetConnectedDevices()
|
||||
{
|
||||
foreach (var ch in GetCandidateLetters())
|
||||
{
|
||||
var identity = TryIdentify(ch);
|
||||
if (identity != null) yield return identity;
|
||||
}
|
||||
}
|
||||
|
||||
static IEnumerable<char> GetCandidateLetters()
|
||||
{
|
||||
return Enumerable.Range(0, 26)
|
||||
.Select(i => (char)('A' + i))
|
||||
.Where(ch => !IGNORE_DRIVES.Contains(ch))
|
||||
.Where(ch =>
|
||||
{
|
||||
string root = $"{ch}:\\";
|
||||
if (!Directory.Exists(root)) return false;
|
||||
try
|
||||
{
|
||||
var dt = Win32.GetDriveType(root);
|
||||
if (dt == DriveType.DRIVE_REMOVABLE) return true;
|
||||
if (dt == DriveType.DRIVE_FIXED) return LooksLikeSDCard(ch, out _, out _, out _);
|
||||
}
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
return false;
|
||||
});
|
||||
}
|
||||
|
||||
static DeviceIdentity? TryIdentify(char ch)
|
||||
{
|
||||
string root = $"{ch}:\\";
|
||||
string label = "";
|
||||
uint serial = 0;
|
||||
bool isRemovable;
|
||||
|
||||
try
|
||||
{
|
||||
var dt = Win32.GetDriveType(root);
|
||||
isRemovable = dt == DriveType.DRIVE_REMOVABLE;
|
||||
|
||||
// LooksLikeSDCard reads label + serial in one shot for fixed drives; for
|
||||
// removable drives we still want the label/serial for the registry id, so do
|
||||
// the read here too.
|
||||
bool looksLikeCamera = false;
|
||||
if (!isRemovable)
|
||||
looksLikeCamera = LooksLikeSDCard(ch, out label, out serial, out _);
|
||||
else
|
||||
ReadVolumeInfo(root, out label, out serial, out _);
|
||||
|
||||
if (!isRemovable && !looksLikeCamera) return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return null; }
|
||||
catch (IOException) { return null; }
|
||||
|
||||
string kind = isRemovable ? "USB" : "SD";
|
||||
string id;
|
||||
if (serial != 0)
|
||||
{
|
||||
id = $"MS:{serial:X8}:{(label ?? "").ToUpperInvariant()}";
|
||||
}
|
||||
else
|
||||
{
|
||||
// No serial — rare, but some virtual/removable drives don't expose one. Fall
|
||||
// back to a weak id (label + volume size) and flag it so the UI can warn.
|
||||
long sizeBytes = TryGetDriveSize(root);
|
||||
id = $"WEAK:{(label ?? "").ToUpperInvariant()}:{sizeBytes}";
|
||||
kind = "USB-weak";
|
||||
}
|
||||
|
||||
string display = string.IsNullOrWhiteSpace(label) ? $"{ch}:\\ ({kind})" : $"{label} ({ch}:)";
|
||||
|
||||
return new DeviceIdentity
|
||||
{
|
||||
Id = id,
|
||||
DisplayName = display,
|
||||
Kind = kind,
|
||||
Photos = new MassStoragePhotoProvider(root)
|
||||
};
|
||||
}
|
||||
|
||||
static bool LooksLikeSDCard(char ch, out string label, out uint serial, out string fsName)
|
||||
{
|
||||
label = ""; serial = 0; fsName = "";
|
||||
string root = $"{ch}:\\";
|
||||
|
||||
// Volume-label keyword heuristic, but also surface label/serial for the caller.
|
||||
ReadVolumeInfo(root, out label, out serial, out fsName);
|
||||
|
||||
if (DCIM_PROBES.Any(p => Directory.Exists(Path.Combine(root, p))))
|
||||
return true;
|
||||
|
||||
string upper = (label ?? "").ToUpperInvariant();
|
||||
return SD_KEYWORDS.Any(kw => upper.Contains(kw));
|
||||
}
|
||||
|
||||
static void ReadVolumeInfo(string root, out string label, out uint serial, out string fsName)
|
||||
{
|
||||
label = ""; serial = 0; fsName = "";
|
||||
var volName = new System.Text.StringBuilder(1024);
|
||||
var fsBuf = new System.Text.StringBuilder(1024);
|
||||
if (Win32.GetVolumeInformationW(root, volName, 1024, out serial, out _, out _, fsBuf, 1024))
|
||||
{
|
||||
label = volName.ToString();
|
||||
fsName = fsBuf.ToString();
|
||||
}
|
||||
}
|
||||
|
||||
static long TryGetDriveSize(string root)
|
||||
{
|
||||
try
|
||||
{
|
||||
var drive = new DriveInfo(root);
|
||||
return drive.TotalSize;
|
||||
}
|
||||
catch (UnauthorizedAccessException) { return 0; }
|
||||
catch (IOException) { return 0; }
|
||||
catch (ArgumentException) { return 0; }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Photo provider for a drive-letter root. Enumerates via the shared mass-storage
|
||||
/// discovery logic in <see cref="DeviceImporter"/>; removal is File.Delete (the
|
||||
/// importer has already copied the file to its destination before calling this).
|
||||
/// </summary>
|
||||
sealed class MassStoragePhotoProvider : IPhotoProvider
|
||||
{
|
||||
readonly string _root;
|
||||
public MassStoragePhotoProvider(string root) { _root = root; }
|
||||
|
||||
public List<string> EnumeratePhotos() => DeviceImporter.FindPhotosOnRoot(_root);
|
||||
|
||||
public bool RemoveSource(string path)
|
||||
{
|
||||
try { if (File.Exists(path)) { File.Delete(path); return true; } }
|
||||
catch (UnauthorizedAccessException) { }
|
||||
catch (IOException) { }
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public enum DriveType : uint
|
||||
{
|
||||
DRIVE_UNKNOWN = 0,
|
||||
DRIVE_NO_ROOT_DIR = 1,
|
||||
DRIVE_REMOVABLE = 2,
|
||||
DRIVE_FIXED = 3,
|
||||
DRIVE_REMOTE = 4,
|
||||
DRIVE_CDROM = 5,
|
||||
DRIVE_RAMDISK = 6
|
||||
}
|
||||
|
||||
public static class Win32
|
||||
{
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern DriveType GetDriveType(string lpRootPathName);
|
||||
|
||||
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
|
||||
public static extern bool GetVolumeInformationW(
|
||||
string rootPathName,
|
||||
System.Text.StringBuilder volumeNameBuffer,
|
||||
int volumeNameSize,
|
||||
out uint volumeSerialNumber,
|
||||
out uint maximumComponentLength,
|
||||
out uint fileSystemFlags,
|
||||
System.Text.StringBuilder fileSystemNameBuffer,
|
||||
int fileSystemNameSize);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,196 @@
|
|||
# AutoIngest — design notes
|
||||
|
||||
**Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net**
|
||||
|
||||
A camera photo importer for ecommerce workflows. Version 3.0.
|
||||
|
||||
---
|
||||
|
||||
## The problem
|
||||
|
||||
A friend sells vintage reel-to-reel film on eBay. His workflow: photograph items with a camera, move the photos to a PC, attach them to listings in the browser. Every photo must be a JPG.
|
||||
|
||||
His cameras shoot a mix of JPG, RAW, and HEIC. Photos land on an SD card and must arrive as JPGs in a date-stamped folder. He is not a technical person. He inserts the card and expects it to work.
|
||||
|
||||
The original tool was Python — PySide6, Pillow, rawpy, pillow-heif, cairosvg, pywin32. Six packages with native C extensions that must compile or match prebuilt wheels for the target Windows version. On a non-technical person's PC, that is a liability.
|
||||
|
||||
## The decision: C# / .NET 8
|
||||
|
||||
The choice came down to one question: what can we hand the seller as a single file that works?
|
||||
|
||||
| Requirement | Python | C# / .NET 8 |
|
||||
|-------------|--------|-------------|
|
||||
| Deploy as single exe | No (interpreter + pip packages) | Yes (`PublishSingleFile`) |
|
||||
| Native image formats | 4+ fragile native libraries | One NuGet package (Magick.NET) |
|
||||
| Drive detection | pywin32 (another native dep) | P/Invoke to kernel32 (zero deps) |
|
||||
| GUI | PySide6 (large native dep) | WinForms (ships with .NET) |
|
||||
| Runtime on target PC | Python 3.x + pip + packages | .NET 8 Desktop Runtime (Win11 default) |
|
||||
| Self-contained option | PyInstaller (fragile, 200+ MB) | `dotnet publish --self-contained` (~150–200 MB, reliable) |
|
||||
|
||||
C# wins on every axis that matters for deployment to a non-technical end user.
|
||||
|
||||
## Architecture
|
||||
|
||||
Three layers, one NuGet dependency, no reverse references:
|
||||
|
||||
```
|
||||
App/ UI layer (namespace AutoIngest.App)
|
||||
Program.cs Single-instance mutex, entry point
|
||||
MainForm.cs Tray, toast, coordinator wiring, Options menu, first-run hook
|
||||
MainForm.Designer.cs Toast popup layout (WinForms Designer)
|
||||
SettingsForm.cs Settings: device registry + retention + access tracking
|
||||
WelcomeForm.cs First-run welcome / branding wizard
|
||||
BrandingForm.cs Branding editor opened from the tray
|
||||
BrandingEditorPanel.cs Shared branding fields + live preview
|
||||
TrayIconFactory.cs Tray icon as a micro brand mark
|
||||
AutostartManager.cs Self-install + Startup shortcut (IShellLinkW COM)
|
||||
Theme.cs Shared dark-theme palette
|
||||
Core/ Config/models layer (namespace AutoIngest.Core)
|
||||
ConfigManager.cs JSON config persistence
|
||||
DeviceRegistry.cs In-memory registry of trusted devices
|
||||
Engine/ Import/conversion layer (namespace AutoIngest.Engine)
|
||||
IDeviceSource.cs Source-of-devices interface
|
||||
DeviceIdentity.cs Connected-device DTO
|
||||
IPhotoProvider.cs Enumerate + remove source photos
|
||||
SDCardMonitor.cs Mass-storage source: kernel32, volume-serial ids
|
||||
MtpDeviceSource.cs Phone source: Shell.Application COM on an STA thread
|
||||
DeviceImporter.cs Shared photo-discovery + move/convert pipeline
|
||||
ImportCoordinator.cs Polling loop, registry gate, retention sweep, event fan-out
|
||||
FolderRetentionPolicy.cs Enumerate dated folders + sweep aged ones to Recycle Bin
|
||||
AccessTimeTracker.cs Detects/enables NTFS last-access-time tracking
|
||||
BrandingRenderer.cs Burns text/logo watermarks into a photo
|
||||
ImageConverter.cs Magick.NET wrapper: orient → brand → strip → copyright → JPG
|
||||
```
|
||||
|
||||
Each class owns one responsibility. The original `SDCardMonitor` did everything (detect, find, import, convert); v3 splits detection (`SDCardMonitor`, `MtpDeviceSource`), importing (`DeviceImporter`), coordination (`ImportCoordinator`), trust (`DeviceRegistry`), and persistence (`ConfigManager`) into separate classes. No god objects.
|
||||
|
||||
### Drive detection
|
||||
|
||||
No WMI. No COM initialization for mass storage. Direct P/Invoke to `kernel32.GetDriveType()` and `kernel32.GetVolumeInformationW()`. The 4-second polling loop checks every drive letter, accepts removable drives and fixed drives that look like cameras (DCIM folder or camera-related volume label), and surfaces each as a `DeviceIdentity` keyed by volume serial.
|
||||
|
||||
### Phone (MTP) detection
|
||||
|
||||
Phones never appear as drive letters — they expose MTP/PTP. `MtpDeviceSource` walks the shell "This PC" namespace via `Shell.Application` COM for portable devices, runs on a dedicated STA thread (shell COM is STA-happiest), and reads each device's PTP serial for the registry fingerprint. Every COM call is wrapped: a locked phone, dead battery, or flaky driver degrades to a log line, never a crash. Phones are strictly best-effort; the SD/USB path is independent.
|
||||
|
||||
### Import pipeline
|
||||
|
||||
1. Discover photos on the device (mass storage: DCIM → Pictures → root → 3-level scan; phone: shell namespace walk).
|
||||
2. Create `~/Pictures/YYYY-MM-DD/`.
|
||||
3. JPG/JPEG: copy through, verify size, reclaim source.
|
||||
4. Everything else: convert via Magick.NET (sRGB, alpha off, progressive optimized JPG, native resolution), re-decode the output to confirm validity, reclaim source.
|
||||
5. Same-day collisions: size then SHA-256. Byte-identical skips; different content gets a `_1`, `_2` suffix (cap 9999, GUID fallback).
|
||||
6. Log every file.
|
||||
|
||||
The pipeline never touches the device directly. Both mass storage (where photos are files and removal is `File.Delete`) and MTP (where photos live behind a COM namespace and removal is a shell verb) feed the same `DeviceImporter` through `IPhotoProvider`.
|
||||
|
||||
### Verify-before-delete
|
||||
|
||||
Every source reclamation follows the same contract: destination exists, destination is the expected size, and (for converts) the destination re-decodes as a valid image. Only then does `RemoveSource` run. The contract is identical for SD/USB and phones — critical because the phone-delete toggle makes phones destructive.
|
||||
|
||||
## Code quality conventions
|
||||
|
||||
- **Targeted exception handling.** Catch blocks name specific types (`UnauthorizedAccessException`, `IOException`, `MagickException`, `JsonException`). Top-of-loop `catch (Exception)` guards exist where any failure must mean "skip this iteration" — the polling loop, per-file import, COM wrapping.
|
||||
- **Named constants over magic numbers.** Intervals (`CHECK_INTERVAL_MS = 4000`), suffix caps (`MAX_COLLISION_SUFFIX = 9999`), colors, skip-lists, retention presets — all named fields.
|
||||
- **Bounded loops.** The polling thread is a `_running`-flag-controlled `while (_running)` with a fixed sleep, not `while (true)`. Collision suffixes cap at 9999 before a GUID fallback.
|
||||
- **Step-down logic.** Guard clauses and early returns throughout (`ResolveDestPath`, `FindPhotos`, `ShowToast`, `BrandingRenderer.Apply`). The common case executes first; edge cases bail early.
|
||||
- **One responsibility per class.** Detection, importing, coordination, trust, persistence each live in their own type.
|
||||
- **Disposable cleanup.** `MainForm.Dispose` releases the tray bitmap, hide-timer, coordinator, HICON, and components. `MagickImage` is `using`-scoped. The MTP provider disposes its staging temp directory on import completion.
|
||||
- **Comments describe why, not what.** Every non-obvious decision carries a rationale comment.
|
||||
|
||||
## Device registry
|
||||
|
||||
The original tool imported from any removable drive. That works for one seller with one SD card. The moment someone else plugs in a USB stick — or the seller plugs in a phone that is not theirs — every image on it moves off and converts. Unacceptable.
|
||||
|
||||
The registry gates all imports. Each device carries a stable fingerprint, not a drive letter:
|
||||
|
||||
- **SD/USB** uses the volume serial number from `GetVolumeInformationW`. The same call the detection code already made; v3 captures the serial instead of discarding it.
|
||||
- **Phones** use the PTP/MTP serial read from the shell namespace.
|
||||
- Devices with no serial fall back to a weak label-plus-size id, flagged in the UI.
|
||||
|
||||
First time a device is seen, the user picks **Register & Import**, **Import Once**, or **Ignore**. Registered devices persist in config and import silently from then on. The registry is editable from **Settings… → Registered devices**.
|
||||
|
||||
## Auto-orient and metadata stripping
|
||||
|
||||
Two conversion-time improvements that are pure wins for the workflow:
|
||||
|
||||
- **Auto-orient.** Phones and some cameras set an EXIF orientation tag without rotating the pixels. The photo looks right in viewers that honor the tag and sideways in many browser upload previews. `image.AutoOrient()` bakes the correction into the pixels before write.
|
||||
- **Strip metadata.** Phone photos embed GPS. A listing photo that pinpoints the seller's house is a privacy hole. `image.Strip()` removes EXIF/XMP/etc. on export.
|
||||
|
||||
Both default on. Both are tray toggles under **Options ▸**. Both apply to converted files only; JPGs that move straight through are byte-for-byte untouched.
|
||||
|
||||
## Branding
|
||||
|
||||
Branding puts the seller's identity on every converted photo: a visible text watermark (store name / handle / URL), a logo watermark, and EXIF copyright fields. Off by default. Configured via the first-launch welcome wizard or the tray editor.
|
||||
|
||||
The interesting part is the pipeline order. The naive approach — strip everything, or embed EXIF and hope strip stays off — either erases the seller's copyright or leaks the camera's metadata. Neither is acceptable.
|
||||
|
||||
The resolved order in `ConvertToJpg`:
|
||||
|
||||
1. `AutoOrient` — bake orientation into pixels.
|
||||
2. Color space / alpha.
|
||||
3. `BrandingRenderer.Apply` — burn watermarks into pixels (survives everything downstream).
|
||||
4. `Strip()` — wipe all metadata: camera model, GPS, software tags, everything.
|
||||
5. Write EXIF copyright — Artist / Copyright / Description are the only metadata written back, so they are the only metadata that survives.
|
||||
6. Encode as JPG.
|
||||
|
||||
Step 4 strips the world. Step 5 writes the seller's stamp onto a clean slate. Camera details are gone; seller attribution remains. Watermarks in step 3 are pixels, so strip is irrelevant to them.
|
||||
|
||||
### Brand tray icon
|
||||
|
||||
The tray icon is the seller's face on the product. Once branding is configured, the icon renders the logo (scaled to fit) or the first letter of the store name on a rounded tile.
|
||||
|
||||
The mark renders at 64×64 (super-sampled) and the code computes the **median luminance** of its non-transparent pixels. Median, not average: a black-and-white logo averages to mid-gray, which turns "light or dark background?" into a coin flip. Median says "this mark has both; pick the background that makes at least one of them pop." Luminance ≥ 0.5 → dark background (`#1f1f1f`); < 0.5 → light background (`#f0f0f0`). Backgrounds are deliberately strong — not mid-tones — so contrast holds on either taskbar theme. The 64×64 composite downsamples to 16×16 with high-quality bicubic interpolation, which is why a glyph drawn at 64px and scaled down reads crisp.
|
||||
|
||||
## Phone delete
|
||||
|
||||
Phones default to copy-and-leave. The phone original stays put; the user curates phone storage. **Options ▸ Delete from phone after import** opts into phone-side deletion after the local copy verifies — mirroring the move behavior SD/USB have always had.
|
||||
|
||||
The delete invokes the shell delete verb on the phone-side object, then verifies the object is gone by re-resolving it. The outcome surfaces in the import log: "removed" only when the delete succeeded, "src kept" when the driver did not honor the verb. Phones only; SD/USB always move/convert as before.
|
||||
|
||||
## Retention
|
||||
|
||||
Once photos accumulate, disk space becomes the next problem. Retention is the answer, but it must be safe — auto-deleting a seller's photos because of a misconfigured setting is worse than running out of space.
|
||||
|
||||
Retention is off by default and strictly opt-in. The seller picks an age (1 week through 7 years), and on a daily sweep plus once at startup any dated import folder older than that age goes to the Recycle Bin — recoverable, not gone.
|
||||
|
||||
Age is measured from the folder's most recent activity, not its creation date. A folder the seller keeps reopening in Explorer stays alive; a folder nothing has touched for six months recycles. Using the max activity timestamp across the folder's files means "any file touched → whole folder is in use," which matches how sellers think about a day's shoot.
|
||||
|
||||
Two safety nets protect active imports:
|
||||
|
||||
- **Today-folder-name exemption.** The folder matching today's date never recycles.
|
||||
- **Freshness guard.** Any folder whose most recent activity is within 10 minutes never recycles, independent of its name. This catches midnight rollover (an import that started at 23:58 writes to yesterday's folder, which would not match today's name but is clearly still active) and clock skew.
|
||||
|
||||
### The LastAccessTime reality
|
||||
|
||||
Windows disables `LastAccessTime` updates by default since Vista for performance. A naive "delete folders whose last-access time is old" implementation deletes everything, because every file's last-access time looks old. AutoIngest handles this in three steps:
|
||||
|
||||
1. **Detect.** `AccessTimeTracker.IsEnabled()` runs `fsutil behavior query DisableLastAccess` and parses the result, including the Win10 1803+ system-managed mode.
|
||||
2. **Fall back honestly.** When access tracking is off, retention uses `LastWriteTime` (import date). The Settings dialog states this plainly.
|
||||
3. **Offer to enable.** A button launches an elevated `fsutil behavior set DisableLastAccess 0` (UAC prompt), with a note that a restart is required.
|
||||
|
||||
### No separate skip-list
|
||||
|
||||
The filesystem timestamps are the access record. Any access resets a folder's clock and the next sweep re-evaluates from scratch. A parallel data structure would need reconciliation with real access events, would drift, and would lose history on reinstall. The sweep is stateless: each pass recomputes every folder's age.
|
||||
|
||||
## Self-install and autostart
|
||||
|
||||
The seller wants AutoIngest to launch at login and live in a stable place rather than Downloads. The classic Win9x method — copy the exe to a system directory and drop a shortcut in the Startup folder — half works on Windows 11.
|
||||
|
||||
**The Startup-folder shortcut: fully supported.** A `.lnk` in `%AppData%\Microsoft\Windows\Start Menu\Programs\Startup\` launches the target at every login. No admin, no UAC, no registry. Explorer honors it exactly as it did twenty-five years ago.
|
||||
|
||||
**The system-root copy: wrong on Win11.** `C:\Windows\` is admin-owned and write-protected. It semantically signals "part of the OS." Defender scrutinizes system-directory binaries harder. A Windows reset can wipe it. Clinging to it means a UAC prompt at install time, breaking the zero-friction posture.
|
||||
|
||||
The install target is `%LocalAppData%\Programs\AutoIngest\` — the modern per-user programs location VS Code and Discord use. Same properties that make the Startup folder viable (user-writable, no admin, per-user, survives reboots), none of the system-root costs.
|
||||
|
||||
The `.lnk` itself is raw `IShellLinkW` + `IPersistFile` COM interop — the old-school way, no `IWshRuntimeLibrary` reference. About fifty lines of interface definitions and a `CoCreateInstance` call.
|
||||
|
||||
The toggle's persisted state is the shortcut's existence: `IsEnabled` is `File.Exists(StartupLink)`. No config flag to drift out of sync with reality. The checkbox reads the filesystem every time the menu opens.
|
||||
|
||||
## Final word
|
||||
|
||||
The Python version was 2900 lines. The C# version is a larger feature surface in a comparable line count, ships as one file, and carries zero runtime dependencies on the target PC in the self-contained build. The seller gets an exe on the desktop and it works. That is the goal.
|
||||
|
||||
---
|
||||
|
||||
**Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net
|
||||
**License:** MIT (MIT License). See [LICENSE](LICENSE).
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2025 Jeremy Anderson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
||||
---
|
||||
|
||||
Project: AutoIngest — camera photo importer for ecommerce workflows.
|
||||
Author: Jeremy Anderson <info@dcos.net>
|
||||
Web: https://dcos.net
|
||||
|
|
@ -0,0 +1,105 @@
|
|||
# Quick Start
|
||||
|
||||
**AutoIngest — running in under 5 minutes.**
|
||||
A Windows tray tool that moves photos off a camera, SD card, USB reader, or phone into a dated folder, converts them to JPG, and applies your branding. Built for ecommerce sellers.
|
||||
|
||||
---
|
||||
|
||||
## 1. Build
|
||||
|
||||
### Prerequisite (build machine only)
|
||||
[Visual Studio 2022](https://visualstudio.microsoft.com/vs/community/) with the **.NET desktop development** workload, or the [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0) standalone. Windows 10/11.
|
||||
|
||||
### Option A — Visual Studio
|
||||
1. Open `AutoIngest.sln`.
|
||||
2. Build → Build Solution (Ctrl+Shift+B).
|
||||
3. Output: `AutoIngest\bin\Release\net8.0-windows\AutoIngest.exe`.
|
||||
|
||||
### Option B — Command line
|
||||
```bat
|
||||
cd AutoIngest
|
||||
dotnet restore
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
## 2. Publish a single exe
|
||||
|
||||
Two profiles ship as scripts. Both produce a single self-contained `AutoIngest.exe` — pick the size/dependency trade-off.
|
||||
|
||||
| Script | Size | Target PC needs |
|
||||
|--------|------|-----------------|
|
||||
| `publish.bat` | ~15 MB | .NET 8 Desktop Runtime (Win11 has it; Win10 may need it) |
|
||||
| `publish-standalone.bat` | ~150–200 MB | Nothing. Runs on any Windows 10/11 PC. |
|
||||
|
||||
```bat
|
||||
publish.bat REM framework-dependent
|
||||
publish-standalone.bat REM self-contained (recommended for handing to a non-technical seller)
|
||||
```
|
||||
|
||||
Output lands in `publish\` or `publish-standalone\`.
|
||||
|
||||
## 3. Deploy
|
||||
|
||||
Copy `AutoIngest.exe` to the target PC. No installer, no admin rights. Put it anywhere the user can run it (Desktop, Downloads, anywhere).
|
||||
|
||||
## 4. First launch
|
||||
|
||||
1. Double-click `AutoIngest.exe`. It starts in the system tray — no window opens.
|
||||
2. On a brand-new install (no config yet), a **welcome wizard** appears:
|
||||
- **Skip for now** — default settings, no branding. Get straight to importing.
|
||||
- **Customize branding** — enter store name / URL / handle, set a text or logo watermark, optionally embed EXIF copyright. A live preview shows the result. The tray icon renders your mark immediately on finish.
|
||||
3. The wizard runs exactly once. Reach branding later from the tray: **Options ▸ Branding ▸ Edit branding…**.
|
||||
|
||||
## 5. Import photos
|
||||
|
||||
1. Insert an SD card, USB reader, or phone.
|
||||
2. The first time a device connects, AutoIngest prompts: **Register & Import** (trusted forever), **Import Once**, or **Ignore**. A friend's device picks Ignore — it is never touched.
|
||||
3. Registered devices import silently on every future plug-in.
|
||||
4. A small toast pops into the bottom-right corner during import:
|
||||
- JPGs copy straight through.
|
||||
- RAW / HEIC / PNG / etc. convert to progressive JPG and the original is removed.
|
||||
- Same-day duplicates (size + SHA-256) skip automatically.
|
||||
5. Photos land in `%USERPROFILE%\Pictures\YYYY-MM-DD\`.
|
||||
|
||||
## 6. Tray menu
|
||||
|
||||
Right-click the tray icon for the menu:
|
||||
|
||||
| Item | Action |
|
||||
|------|--------|
|
||||
| **Show status** | Pop the toast to peek at the log |
|
||||
| **Open import folder** | Open today's `Pictures\YYYY-MM-DD\` in Explorer |
|
||||
| **Settings…** | Device registry, retention, access-tracking |
|
||||
| **Reset drive memory** | Re-scan devices already seen this session |
|
||||
| **Options ▸** | Auto-orient, Strip metadata, Delete from phone after import, Branding, Start with Windows |
|
||||
| **About** | Version, author, license |
|
||||
| **Exit** | Quit |
|
||||
|
||||
Left-click the tray icon to toggle the toast.
|
||||
|
||||
## 7. Recommended first-run setup for a seller
|
||||
|
||||
1. Complete the welcome wizard with the store name and a logo if available.
|
||||
2. Confirm **Options ▸ Auto-orient** and **Options ▸ Strip metadata** are on (defaults) — phones photos arrive right-way-up without leaking GPS.
|
||||
3. **Options ▸ Start with Windows** → on, so the seller never has to launch it.
|
||||
4. Plug in the seller's SD card → **Register & Import**. Done forever.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Problem | Resolution |
|
||||
|---------|------------|
|
||||
| `dotnet` not found | Install [.NET 8 SDK](https://dotnet.microsoft.com/download/dotnet/8.0). |
|
||||
| Magick.NET restore fails | Check internet; NuGet.org must be reachable. |
|
||||
| Target PC says "missing runtime" | Use `publish-standalone.bat` (self-contained). |
|
||||
| SD card not detected | Verify it mounts as a drive letter. Right-click tray → Show status for the log. |
|
||||
| Phone not detected | Unlock the phone and tap "Trust this PC." MTP enumeration needs an unlocked, trusted device. |
|
||||
| Photos land in the wrong place | Destination is `%USERPROFILE%\Pictures\YYYY-MM-DD\`. OneDrive redirection can move `Pictures` — check there too. |
|
||||
| Branding not applying | Branding applies to **converted** files only. JPGs that move straight through are byte-for-byte untouched. |
|
||||
| Retention not firing | Retention is off by default. Enable in **Settings…** and pick an age. The sweep runs on startup then daily. |
|
||||
| "Delete from phone" log says "src kept" | The phone driver did not honor the delete verb. The local copy is fine; the phone original remains. Re-run the import or delete manually. |
|
||||
| "Photos disappeared" / need a record of what happened | A persistent log lives at `%LocalAppData%\AutoIngest\autoingest.log` (with a `.bak` for the prior rotation). It records every import, retention sweep, and crash. Open it in Notepad. |
|
||||
|
||||
---
|
||||
|
||||
**Author:** Jeremy Anderson — [dcos.net](https://dcos.net) — info@dcos.net
|
||||
**License:** MIT. See [LICENSE](LICENSE).
|
||||
|
|
@ -0,0 +1,207 @@
|
|||
# AutoIngest
|
||||
|
||||
**A Windows tray-resident photo importer for ecommerce sellers.**
|
||||
Jeremy Anderson · [dcos.net](https://dcos.net) · info@dcos.net
|
||||
Version 3.1 · MIT licensed
|
||||
|
||||
---
|
||||
|
||||
## What it does
|
||||
|
||||
Plug in a camera, SD card, USB reader, or phone. AutoIngest moves the photos into a dated folder (`Pictures\YYYY-MM-DD\`), converts any non-JPG format to progressive JPG, applies your branding, and gets out of the way. Built for a seller who photographs items and needs JPGs in a folder with zero clicks.
|
||||
|
||||
The four pillars:
|
||||
|
||||
1. **Import.** Photos move off the device into `~/Pictures/<today>/`. JPGs copy straight through; RAW/HEIC/PNG convert to JPG via Magick.NET. Same-day dedupe (size then SHA-256) skips re-imports.
|
||||
2. **Trust.** An opt-in device registry decides what imports. A friend's USB stick or phone is never touched. Each device is fingerprinted by volume serial (SD/USB) or PTP serial (phone) — never by drive letter.
|
||||
3. **Brand.** A welcome wizard on first launch sets up your store identity: a text or logo watermark burned into every converted photo, plus EXIF copyright that survives metadata stripping.
|
||||
4. **Maintain.** Optional retention sweeps dated folders older than a threshold to the Recycle Bin. Optional autostart installs the exe to a stable home and launches it at login.
|
||||
|
||||
## Features
|
||||
|
||||
### Import pipeline
|
||||
- Polls mass-storage drives (kernel32 `GetDriveType` / `GetVolumeInformationW`) and MTP phones (`Shell.Application` COM on an STA thread) every 4 seconds
|
||||
- Mass storage: probes DCIM / Pictures / root / 3-level deep scan
|
||||
- MTP: walks the device shell namespace for DCIM-equivalent folders
|
||||
- JPG/JPEG files copy through; everything else converts via Magick.NET (30+ formats: RAW, HEIC, PNG, TIFF, WebP, SVG, …)
|
||||
- Output is progressive, optimized JPG at native resolution
|
||||
- Verify-before-delete: every source is reclaimed only after the destination is confirmed present, correctly sized, and (for converts) re-decoded
|
||||
- Same-day duplicate aware: byte-identical files skipped; name collisions with different content get `_1`, `_2` suffixes (capped at 9999, GUID fallback)
|
||||
- Single-instance enforcement via named mutex
|
||||
|
||||
### Device registry (opt-in trust)
|
||||
- First time a device connects, choose **Register & Import**, **Import Once**, or **Ignore**
|
||||
- Registered devices are keyed by a stable fingerprint:
|
||||
- SD/USB → volume serial from `GetVolumeInformationW`
|
||||
- Phone → PTP/MTP serial via the shell namespace
|
||||
- No serial available → weak fallback (label + size), flagged in the UI
|
||||
- A friend's unregistered device is silently skipped (or prompts, per your setting)
|
||||
- Manage from the tray: **Settings… → Registered devices**
|
||||
|
||||
### Image quality toggles (Options ▸)
|
||||
- **Auto-orient (default on)** — honors the EXIF orientation tag by rotating the pixels, so phone photos do not land sideways in browser uploads
|
||||
- **Strip metadata (default on)** — removes EXIF/XMP/GPS on export so listing photos do not leak the seller's location
|
||||
- **Delete from phone after import (default off)** — phones only; mirrors the move behavior SD/USB have always had. SD/USB always move/convert as before
|
||||
|
||||
### Branding (optional, off by default)
|
||||
Set up via the first-launch welcome wizard, or later from **Options ▸ Branding ▸ Edit branding…**:
|
||||
- **Text watermark** — store name / handle / URL burned onto the photo (position + opacity configurable, live preview)
|
||||
- **Logo watermark** — PNG overlaid in a corner (scale + opacity configurable)
|
||||
- **EXIF copyright** — Artist / Copyright / Description embedded as the *only* surviving metadata: strip runs first (camera/GPS/software tags gone), then your copyright writes back
|
||||
- **Brand tray icon** — the tray icon renders your logo or store initial on a rounded tile whose background auto-contrasts the mark (light mark → dark tile, dark mark → light tile)
|
||||
- Applies to converted files only (same scope as auto-orient / strip)
|
||||
|
||||
### Photo retention (optional, off by default)
|
||||
- Dated folders older than a chosen age go to the **Recycle Bin** on a daily sweep (and once on startup)
|
||||
- Age is measured by most recent activity across the folder's files — access a file and the folder's clock resets
|
||||
- Presets: 1 week, 1 month, 3 months, 6 months, 12 months, 2 years, 7 years
|
||||
- Safety rails: today's folder is exempt; a 10-minute freshness guard protects active imports against midnight rollover; non-dated folders are never touched; everything is recoverable
|
||||
- Access-tracking aware: detects NTFS `LastAccessTime` state and falls back to import date when Windows disables access updates (the default)
|
||||
|
||||
### Start with Windows (optional toggle)
|
||||
- **Options ▸ Start with Windows** copies the exe to `%LocalAppData%\Programs\AutoIngest\` and places a shortcut in the user's Startup folder (`shell:startup`)
|
||||
- No admin rights, no UAC, no registry entry. Per-user. Takes effect at next login
|
||||
- The shortcut's existence is the source of truth — the checkbox reflects reality, refreshed every time the menu opens
|
||||
|
||||
### Toast UI
|
||||
- Lives in the system tray by default — no window at startup
|
||||
- A borderless toast pops into the bottom-right corner during an import (or when an unknown device connects)
|
||||
- Live status line (`Moving 3/12: IMG_004.jpg`) and a scrolling log
|
||||
- Auto-hides ~4 seconds after the import completes
|
||||
- Left-click the tray icon to peek; right-click for the menu
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
AutoIngest/
|
||||
├── AutoIngest.csproj .NET 8 / WinForms / single NuGet dependency
|
||||
├── App/ UI layer (namespace AutoIngest.App)
|
||||
│ ├── Program.cs Entry point, single-instance mutex
|
||||
│ ├── MainForm.cs Tray icon, toast, coordinator wiring, Options menu
|
||||
│ ├── MainForm.Designer.cs Toast popup layout (WinForms Designer)
|
||||
│ ├── MainForm.resx Designer resource header
|
||||
│ ├── SettingsForm.cs Settings: device registry + retention + access tracking
|
||||
│ ├── WelcomeForm.cs First-run welcome / branding wizard (new installs only)
|
||||
│ ├── BrandingForm.cs Branding editor opened from the tray
|
||||
│ ├── BrandingEditorPanel.cs Shared branding fields + live preview
|
||||
│ ├── TrayIconFactory.cs Builds the tray icon as a micro brand mark
|
||||
│ ├── AutostartManager.cs Self-install + Startup-folder shortcut (IShellLinkW COM)
|
||||
│ ├── Theme.cs Shared dark-theme palette
|
||||
│ └── app.manifest Windows 10/11 compatibility manifest
|
||||
├── Core/ Config/models layer (namespace AutoIngest.Core)
|
||||
│ ├── ConfigManager.cs JSON config persistence (atomic write, .bak fallback)
|
||||
│ ├── DeviceRegistry.cs In-memory registry of trusted devices
|
||||
│ └── FileLogger.cs Rolling persistent log at %LocalAppData%\AutoIngest\
|
||||
└── Engine/ Import/conversion layer (namespace AutoIngest.Engine)
|
||||
├── IDeviceSource.cs Interface for a source of connected devices
|
||||
├── DeviceIdentity.cs Connected-device DTO
|
||||
├── IPhotoProvider.cs Interface for enumerating + removing source photos
|
||||
├── SDCardMonitor.cs Mass-storage source (SD/USB) — volume-serial ids
|
||||
├── MtpDeviceSource.cs Phone source — Shell.Application COM on an STA thread
|
||||
├── DeviceImporter.cs Shared photo-discovery + move/convert pipeline
|
||||
├── ImportCoordinator.cs Polling loop, registry gate, retention sweep, event fan-out
|
||||
├── FolderRetentionPolicy.cs Enumerates dated folders + sweeps aged ones to Recycle Bin
|
||||
├── AccessTimeTracker.cs Detects/enables NTFS last-access-time tracking
|
||||
├── BrandingRenderer.cs Burns text/logo watermarks into a photo
|
||||
├── FileSize.cs Shared human-readable byte formatting
|
||||
└── ImageConverter.cs Magick.NET wrapper: orient → brand → strip → copyright → JPG
|
||||
```
|
||||
|
||||
**Single NuGet dependency:** `Magick.NET-Q16-AnyCPU`. Everything else (drive detection, shell COM, recycle bin, shortcut creation, access-time probing) uses OS-provided APIs.
|
||||
|
||||
**Layering:** `App → Core`, `App → Engine`, `Engine → Core`. No reverse references. Each class owns one responsibility.
|
||||
|
||||
## Design decisions
|
||||
|
||||
| Decision | Rationale |
|
||||
|----------|-----------|
|
||||
| Move, not copy | The card is the source of truth; duplicates after import cause confusion. Move once, done. |
|
||||
| Copy-and-leave for phones by default | Phones are not a filesystem you can `File.Move` from; auto-deleting from a phone is risky. Opt-in toggle mirrors the move behavior. |
|
||||
| Opt-in device registry | A friend's USB stick or phone must never auto-import. Each device is approved once and remembered by serial, not drive letter. |
|
||||
| Strip-everything-then-write-branding | Camera model, GPS, and software tags are wiped; the seller's copyright writes back as the sole surviving metadata. Privacy and attribution in one pipeline. |
|
||||
| Retention off by default | Auto-deleting photos is dangerous. The seller picks a threshold; nothing happens until they do. |
|
||||
| Recycle Bin, not permanent delete | Mistakes recover. Aged folders go to the Recycle Bin. |
|
||||
| Filesystem timestamps are the access list | Any access resets a folder's clock. No parallel skip-list to maintain or drift. |
|
||||
| Honest access-tracking detection | Windows disables last-access times by default; we detect it, explain it, and offer to enable it. |
|
||||
| Startup-folder shortcut | The classic autorun mechanism, fully supported on Windows 11. No admin, no UAC. |
|
||||
| `%LocalAppData%\Programs` install target | User-writable, per-user, survives reboots. The system root (`C:\Windows\`) is admin-owned and Defender-scrutinized. |
|
||||
| C# / .NET 8 over Python | Single-exe deployment. No runtime or pip on the target PC. |
|
||||
| WinForms over WPF | Direct control layout, no XAML complexity. |
|
||||
| Magick.NET over System.Drawing | One library handles all 30+ formats including RAW, HEIC, SVG. |
|
||||
| Polling (4 s) over WMI events | Deterministic, no COM apartment threading, consistent across Windows versions. |
|
||||
| Flat output directory | Matches the workflow: pull to date folder, open in browser, attach to listing. |
|
||||
|
||||
## Building
|
||||
|
||||
### Prerequisites
|
||||
- Windows 10/11
|
||||
- [Visual Studio 2022](https://visualstudio.microsoft.com/vs/community/) with the **.NET desktop development** workload, or the .NET 8 SDK standalone
|
||||
|
||||
### Visual Studio
|
||||
1. Open `AutoIngest.sln`
|
||||
2. Build → Build Solution (Ctrl+Shift+B)
|
||||
3. Output: `AutoIngest/bin/Debug/net8.0-windows/AutoIngest.exe`
|
||||
|
||||
### Command line
|
||||
```bat
|
||||
cd AutoIngest
|
||||
dotnet restore
|
||||
dotnet build -c Release
|
||||
```
|
||||
|
||||
### Publish a single exe
|
||||
```bat
|
||||
REM Framework-dependent (~15 MB, requires .NET 8 Desktop Runtime on target)
|
||||
publish.bat
|
||||
|
||||
REM Self-contained (~150-200 MB, runs on any Windows 10/11 PC)
|
||||
publish-standalone.bat
|
||||
```
|
||||
|
||||
See **QUICKSTART.md** for the 5-minute end-to-end flow.
|
||||
|
||||
## Deployment
|
||||
|
||||
Copy `AutoIngest.exe` to the target PC. No installer required.
|
||||
|
||||
- **Framework-dependent build** requires the .NET 8 Desktop Runtime. Windows 11 includes it; Windows 10 may need it from [dotnet.microsoft.com](https://dotnet.microsoft.com/download/dotnet/8.0).
|
||||
- **Self-contained build** has no prerequisites. Runs on any Windows 10/11 PC.
|
||||
|
||||
## Configuration
|
||||
|
||||
Config file: `~/.autoingest_config.json` — created on first run. Fields:
|
||||
|
||||
| Field | Default | Purpose |
|
||||
|-------|---------|---------|
|
||||
| `JpgQuality` | 92 | Output JPG quality |
|
||||
| `AutoImport` | true | Whether the monitor runs on startup |
|
||||
| `PromptOnUnknown` | true | Whether an unregistered device prompts vs. is silently skipped |
|
||||
| `Devices` | `[]` | Opt-in registry: stable id, name, kind, added date |
|
||||
| `RetentionEnabled` | false | Aged-folder auto-recycle |
|
||||
| `RetentionDays` | 180 | Age threshold in days |
|
||||
| `AutoOrient` | true | EXIF orientation correction |
|
||||
| `StripExif` | true | Strip EXIF/GPS/XMP metadata |
|
||||
| `DeleteFromPhoneAfterImport` | false | Phone-side delete after verified copy (phones only) |
|
||||
| `SetupCompleted` | true | Welcome-wizard gating marker |
|
||||
| `Branding` | (object) | Enabled flag, store identity, watermark settings, EXIF copyright |
|
||||
|
||||
The registry, retention, and branding are editable from the UI (tray → **Settings…** and **Options ▸ Branding ▸ Edit branding…**). Hand-editing the JSON is supported but unnecessary.
|
||||
|
||||
## Tech stack
|
||||
|
||||
| Component | Technology |
|
||||
|-----------|------------|
|
||||
| Language | C# 12 / .NET 8 |
|
||||
| UI framework | Windows Forms |
|
||||
| Image processing | Magick.NET-Q16-AnyCPU (ImageMagick 7) |
|
||||
| Mass-storage detection | kernel32 `GetDriveType` + `GetVolumeInformationW` via P/Invoke |
|
||||
| Phone (MTP) detection | `Shell.Application` COM interop on an STA thread |
|
||||
| Recycle Bin | `Microsoft.VisualBasic.FileIO` (BCL) |
|
||||
| Shortcut creation | `IShellLinkW` + `IPersistFile` COM interop |
|
||||
| Access-time probe | `fsutil` via `System.Diagnostics.Process` |
|
||||
| Serialization | System.Text.Json |
|
||||
| Target | Windows 10 1809+ / Windows 11 |
|
||||
|
||||
## License
|
||||
|
||||
MIT License. See [LICENSE](LICENSE).
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
@echo off
|
||||
echo ============================================
|
||||
echo AutoIngest - Standalone Build
|
||||
echo (no .NET runtime required on target PC)
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM Check for .NET 8 SDK
|
||||
where dotnet >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: .NET 8 SDK not found.
|
||||
echo Install it from: https://dotnet.microsoft.com/download/dotnet/8.0
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Restoring NuGet packages...
|
||||
cd /d "%~dp0AutoIngest"
|
||||
dotnet restore
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: NuGet restore failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Building Standalone Release...
|
||||
echo (this will take a moment...)
|
||||
dotnet publish -c Release -r win-x64 --self-contained true -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ..\publish-standalone
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: Build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo BUILD SUCCESS
|
||||
echo Output: publish-standalone\AutoIngest.exe
|
||||
echo File size will be ~150-200 MB (includes everything)
|
||||
echo ============================================
|
||||
echo.
|
||||
echo This exe runs on ANY Windows 10/11 PC - no runtime needed.
|
||||
echo.
|
||||
pause
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
@echo off
|
||||
echo ============================================
|
||||
echo AutoIngest - Build and Publish
|
||||
echo ============================================
|
||||
echo.
|
||||
|
||||
REM Check for .NET 8 SDK
|
||||
where dotnet >nul 2>&1
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: .NET 8 SDK not found.
|
||||
echo Install it from: https://dotnet.microsoft.com/download/dotnet/8.0
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo Restoring NuGet packages...
|
||||
cd /d "%~dp0AutoIngest"
|
||||
dotnet restore
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: NuGet restore failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo Building Release...
|
||||
dotnet publish -c Release -r win-x64 --self-contained false -p:PublishSingleFile=true -p:IncludeNativeLibrariesForSelfExtract=true -o ..\publish
|
||||
if %errorlevel% neq 0 (
|
||||
echo ERROR: Build failed.
|
||||
pause
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
echo.
|
||||
echo ============================================
|
||||
echo BUILD SUCCESS
|
||||
echo Output: publish\AutoIngest.exe
|
||||
echo ============================================
|
||||
echo.
|
||||
echo NOTE: This requires .NET 8 Desktop Runtime on the target PC.
|
||||
echo Windows 11 already has it. For Windows 10, get it from:
|
||||
echo https://dotnet.microsoft.com/download/dotnet/8.0
|
||||
echo.
|
||||
pause
|
||||
Loading…
Reference in New Issue