From 24e0072c2192fabbafc16527171c3606163669f9 Mon Sep 17 00:00:00 2001 From: Jared Knowles Date: Mon, 20 Apr 2026 16:54:52 -0400 Subject: [PATCH] feat: add PowerShell port for Windows users without Python Adds tagsync.ps1, a self-contained PowerShell rewrite of the full tagsync tool. Uses Invoke-RestMethod for Dropbox REST calls, DPAPI- encrypted file for token storage, JSON for state, and native Register-ScheduledTask for scheduling. Only external dependency is exiftool.exe (same as the Python version). Updates README to lead with the PowerShell quick start, with Python instructions in a separate section below. --- README.md | 233 +++++++++------- tagsync.ps1 | 756 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 890 insertions(+), 99 deletions(-) create mode 100644 tagsync.ps1 diff --git a/README.md b/README.md index b197db1..d2cb04e 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # Dropbox Tag Sync Bring Dropbox web tags down to your computer so you can search for tagged -files in Finder (macOS) or File Explorer (Windows) as if they were +files in File Explorer (Windows) or Finder (macOS) as if they were first-class local tags. ## The problem this solves @@ -17,74 +17,149 @@ This tool closes that gap. It connects to Dropbox, reads each file's tags, and writes them to your local files in a form your operating system treats as a first-class tag. -## Quick start +--- + +## Windows — PowerShell (no Python required) + +**Requirements:** Windows 10/11, PowerShell 5.1+, [ExifTool](https://exiftool.org/) on your PATH. + +### Quick start + +```powershell +.\tagsync.ps1 wizard +``` + +If you get an execution policy error, run this first (once, per machine): + +```powershell +Set-ExecutionPolicy -Scope CurrentUser -ExecutionPolicy RemoteSigned +``` + +### Commands + +All commands are run as `.\tagsync.ps1 `. + +| Command | What it does | +|---|---| +| `wizard` | Interactive setup (run this first) | +| `run` | One-shot sync using saved settings | +| `run -DryRun` | Show what would change without touching files | +| `run -Verbose` | Show every file action during sync | +| `status` | Show config, token presence, and schedule state | +| `schedule install` | Install the background scheduled task | +| `schedule install -Interval 30` | Install, running every 30 minutes | +| `schedule uninstall` | Remove the background scheduled task | +| `schedule status` | Show whether the scheduled task is installed | +| `reset -All` | Clear stored token, settings, and local state | +| `reset -Token` | Clear only the token | + +### Where things are stored (Windows / PowerShell) + +| Item | Location | +|---|---| +| Token | `~\.dropbox_tag_sync\token.dpapi` (DPAPI-encrypted, current user only) | +| Settings | `~\.dropbox_tag_sync\config.json` | +| Local state cache | `~\.dropbox_tag_sync\state.json` | +| Scheduled task | Task Scheduler, name: `DropboxTagSync` | + +### Troubleshooting (Windows / PowerShell) + +**"exiftool not found"** — install from , place +`exiftool.exe` on your PATH (e.g. `C:\Windows\`), then confirm +`exiftool -ver` works in a terminal. + +**Windows tags don't show in Explorer** — Explorer's Tags column may +be hidden; right-click a column header and add **Tags**. Windows Search +may need a few minutes to re-index. + +**"not found locally"** — the file exists in Dropbox but hasn't synced +down yet. Let Dropbox finish syncing, or mark the file as "always keep +on this device" if it is online-only. + +**Token wrong or revoked** — run `.\tagsync.ps1 reset -Token`, then +`.\tagsync.ps1 wizard` to enter a new one. + +**State got into a weird state** — run `.\tagsync.ps1 reset -State`. +The next run will re-examine every file; no data is lost. + +--- + +## macOS / Windows — Python + +**Requirements:** Python 3.9+, packages in `requirements.txt`, and +(Windows only) [ExifTool](https://exiftool.org/) on your PATH. + +### Quick start ``` pip install -r requirements.txt python tagsync.py wizard ``` -That's it. The wizard detects your OS, finds your Dropbox folder, walks -you through creating a read-only Dropbox access token, previews what -will be tagged, applies the tags, and offers to install a background -scheduler so new tags keep flowing down automatically. +The wizard detects your OS, finds your Dropbox folder, walks you through +creating a read-only Dropbox access token, previews what will be tagged, +applies the tags, and offers to install a background scheduler. -Everything after that is optional — if you want to re-run a sync, -check status, or manage the schedule, use the other subcommands below. - -## Requirements - -- **Python 3.9+** -- The packages in `requirements.txt` (`pip install -r requirements.txt`) -- **Windows only:** [ExifTool](https://exiftool.org/) on your `PATH` - -## What's in this repo - -| File / folder | Purpose | -| ------------------------ | ------------------------------------------ | -| `tagsync.py` | Single CLI entry point | -| `tagsync/` | Package with all the logic | -| `requirements.txt` | Python dependencies | -| `README.md` | This document | - -Inside `tagsync/`: - -| File | Purpose | -| ------------------------- | --------------------------------------------- | -| `wizard.py` | Interactive setup flow | -| `core.py` | Dropbox listing, tag fetching, sync loop | -| `config.py` | Settings file + secure token storage | -| `detect.py` | OS / Dropbox folder / dependency detection | -| `platform_macos.py` | Finder-xattr writer + launchd scheduler | -| `platform_windows.py` | ExifTool-based XMP writer + Task Scheduler | - -## Commands +### Commands All commands are run as `python tagsync.py `. -| Command | What it does | -| ------------------------------ | ---------------------------------------------------- | -| `wizard` | Interactive setup (run this first) | -| `run` | Perform a one-shot sync using saved settings | -| `run --dry-run` | Show what would change without touching any files | -| `status` | Show config, token presence, and schedule state | -| `schedule install` | Install the background scheduled task | -| `schedule install --interval 30` | Install, running every 30 minutes | -| `schedule uninstall` | Remove the background scheduled task | -| `schedule status` | Show whether the scheduler is installed and loaded | -| `reset --all` | Clear stored token, settings, and local state DB | -| `reset --token` | Clear only the token | +| Command | What it does | +|---|---| +| `wizard` | Interactive setup (run this first) | +| `run` | Perform a one-shot sync using saved settings | +| `run --dry-run` | Show what would change without touching any files | +| `status` | Show config, token presence, and schedule state | +| `schedule install` | Install the background scheduled task | +| `schedule install --interval 30` | Install, running every 30 minutes | +| `schedule uninstall` | Remove the background scheduled task | +| `schedule status` | Show whether the scheduler is installed and loaded | +| `reset --all` | Clear stored token, settings, and local state DB | +| `reset --token` | Clear only the token | + +### What's in this repo + +| File / folder | Purpose | +|---|---| +| `tagsync.ps1` | PowerShell entry point (Windows, no Python) | +| `tagsync.py` | Python CLI entry point | +| `tagsync/` | Python package with all the logic | +| `requirements.txt` | Python dependencies | + +Inside `tagsync/`: + +| File | Purpose | +|---|---| +| `wizard.py` | Interactive setup flow | +| `core.py` | Dropbox listing, tag fetching, sync loop | +| `config.py` | Settings file + secure token storage | +| `detect.py` | OS / Dropbox folder / dependency detection | +| `platform_macos.py` | Finder-xattr writer + launchd scheduler | +| `platform_windows.py` | ExifTool-based XMP writer + Task Scheduler | + +### Where things are stored (Python) + +| Item | Location | +|---|---| +| Token (preferred) | macOS Keychain / Windows Credential Manager | +| Token (fallback) | `~/.dropbox_tag_sync/token` (mode 0600) | +| Settings | `~/.dropbox_tag_sync/config.json` | +| Local state cache | `~/.dropbox_tag_sync/state.db` | +| Log (macOS) | `~/Library/Logs/dropbox-tag-sync.log` | +| launchd plist | `~/Library/LaunchAgents/com.dropboxtagsync.agent.plist` | +| Windows task | Task Scheduler, name: `DropboxTagSync` | + +--- ## How it works 1. You create a Dropbox access token with **read-only** metadata scope. - The wizard walks you through this. The token lives in your OS - keyring (macOS Keychain or Windows Credential Manager). + The wizard walks you through this. 2. The tool lists every file in your Dropbox and asks Dropbox which tags each file has. 3. For files whose tags have changed since the last run, it writes those tags to the local copy. -4. A small SQLite cache remembers what was written so subsequent runs +4. A small state cache remembers what was written so subsequent runs only touch files whose tags actually changed. Nothing is ever deleted from Dropbox. The tool only **reads** from @@ -92,14 +167,13 @@ Dropbox and **writes** to local files on your machine. ## The important platform difference -macOS and Windows store "file tags" in fundamentally different ways, -and that shapes what happens under the hood. +macOS and Windows store "file tags" in fundamentally different ways. ### macOS — clean and invisible macOS has a universal, built-in tag system. Tags are stored in an -**extended attribute** (`xattr`) alongside the file, not inside it. The -tool writes the xattr directly. Consequences: +**extended attribute** (`xattr`) alongside the file, not inside it. +The tool writes the xattr directly. Consequences: - Every file type can be tagged (text, zip, source code, anything). - The file's contents are untouched, so Dropbox does **not** see the @@ -110,7 +184,6 @@ tool writes the xattr directly. Consequences: Windows has no universal tag store. The "Tags" field in Explorer is actually the **XMP:Subject** metadata field embedded inside the file. -This is a limitation of the operating system, not the tool. Consequences: @@ -119,19 +192,15 @@ Consequences: by default and can optionally write a `.tags.json` sidecar next to them instead. - Because the tag lives inside the file, writing it **does** modify - the file. Dropbox will notice and re-upload the file once. After - that first sync, tags are stored in both the cloud and the local - copy, and future runs only touch files whose Dropbox tags change - again. + the file. Dropbox will notice and re-upload the file once. - If Dropbox Smart Sync leaves some files online-only, tagging one would trigger a download. The tool can skip online-only files (on by default in the wizard). > **Plan for the first Windows run.** If there are many tagged files, -> the first run will queue a large number of re-uploads as each file -> gets its tags embedded. Schedule it overnight, on a good connection, -> and expect a one-time spike in Dropbox activity. Subsequent runs are -> quiet. +> the first run will queue a large number of re-uploads. Schedule it +> overnight, on a good connection, and expect a one-time spike in +> Dropbox activity. Subsequent runs are quiet. ## One-time Dropbox app setup @@ -143,8 +212,7 @@ reference: - **Scoped access** - **Full Dropbox** - A name of your choosing (e.g. `tag-sync`) -3. Open the **Permissions** tab and enable: - - `files.metadata.read` +3. Open the **Permissions** tab and enable `files.metadata.read`. 4. Click **Submit**. 5. On the **Settings** tab, scroll to **OAuth 2 → Generated access token** and click **Generate**. Paste that token into the wizard. @@ -152,39 +220,6 @@ reference: The generated token only has permission to read file metadata. It cannot modify anything in Dropbox even if it leaked. -## Where things are stored - -| Item | Location | -| ------------------ | ---------------------------------------------------- | -| Token (preferred) | macOS Keychain / Windows Credential Manager | -| Token (fallback) | `~/.dropbox_tag_sync/token` (mode 0600) | -| Settings | `~/.dropbox_tag_sync/config.json` | -| Local state cache | `~/.dropbox_tag_sync/state.db` | -| Log (macOS) | `~/Library/Logs/dropbox-tag-sync.log` | -| launchd plist | `~/Library/LaunchAgents/com.dropboxtagsync.agent.plist` | -| Windows task | Task Scheduler, name: `DropboxTagSync` | - -## Troubleshooting - -**"not found locally"** — the file exists in Dropbox but hasn't synced -down yet. Let Dropbox finish syncing, or (on Windows) mark the file -as "always keep on this device" if it's online-only. - -**Windows tags don't show up in Explorer after a run** — Explorer's -Tags column may be hidden; right-click a column header and add -**Tags**. Windows Search may need a few minutes to re-index. - -**"exiftool not found"** — install it from -and confirm `exiftool -ver` works in a terminal. - -**The state cache got into a weird state** — run -`python tagsync.py reset --state`. The next run will re-examine every -file; no data is lost. - -**The token is wrong or revoked** — run -`python tagsync.py reset --token`, then `python tagsync.py wizard` -to enter a new one. - ## Safety summary - **Read-only** with Dropbox (token scope: `files.metadata.read`). diff --git a/tagsync.ps1 b/tagsync.ps1 new file mode 100644 index 0000000..b84cc98 --- /dev/null +++ b/tagsync.ps1 @@ -0,0 +1,756 @@ +<# +.SYNOPSIS + Dropbox Tag Sync — PowerShell edition (Windows only, no Python required). + +.DESCRIPTION + Pulls Dropbox web tags down to Windows file XMP metadata so they appear + as first-class Tags in File Explorer and Windows Search. + + Requires exiftool.exe on your PATH or in C:\Windows\. + + Subcommands: + wizard Interactive setup (run this first) + run One-shot sync using saved settings + status Show configuration and schedule state + schedule install | uninstall | status + reset -Token | -Settings | -State | -All + +.EXAMPLE + .\tagsync.ps1 wizard + .\tagsync.ps1 run + .\tagsync.ps1 run -DryRun + .\tagsync.ps1 status + .\tagsync.ps1 schedule install -Interval 60 + .\tagsync.ps1 schedule uninstall + .\tagsync.ps1 reset -All +#> + +[CmdletBinding()] +param( + [Parameter(Position = 0)] + [ValidateSet('wizard','run','status','schedule','reset')] + [string]$Command = '', + + # run + [switch]$DryRun, + + # schedule + [ValidateSet('install','uninstall','status')] + [string]$Action = '', + [int]$Interval = 0, + + # reset + [switch]$Token, + [switch]$Settings, + [switch]$State, + [switch]$All +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = 'Stop' + +$SCRIPT_PATH = $PSCommandPath + +# --------------------------------------------------------------------------- +# Paths & constants +# --------------------------------------------------------------------------- +$CONFIG_DIR = Join-Path $env:USERPROFILE '.dropbox_tag_sync' +$CONFIG_FILE = Join-Path $CONFIG_DIR 'config.json' +$STATE_FILE = Join-Path $CONFIG_DIR 'state.json' +$TASK_NAME = 'DropboxTagSync' +$CRED_TARGET = 'dropbox_tag_sync/default' +$TAG_BATCH = 100 + +$XMP_EXTS = [System.Collections.Generic.HashSet[string]]@( + '.jpg','.jpeg','.jpe','.jfif','.png','.tif','.tiff','.gif', + '.heic','.heif','.webp','.psd','.dng','.cr2','.cr3','.nef', + '.arw','.orf','.raf','.rw2','.svg', + '.pdf','.docx','.xlsx','.pptx','.odt','.ods','.odp','.epub', + '.mp3','.m4a','.wav','.flac','.aac','.ogg', + '.mp4','.m4v','.mov','.avi','.mkv' +) + +# --------------------------------------------------------------------------- +# UI helpers +# --------------------------------------------------------------------------- +function Write-Header([string]$Title) { + $bar = '=' * [Math]::Max($Title.Length, 40) + Write-Host '' + Write-Host $Title -ForegroundColor White + Write-Host $bar -ForegroundColor White +} + +function Write-Step([string]$N, [string]$Title) { + Write-Host '' + Write-Host "[$N] " -NoNewline -ForegroundColor Cyan + Write-Host $Title -ForegroundColor White +} + +function Write-Info([string]$Msg) { Write-Host " $Msg" } +function Write-Dim([string]$Msg) { Write-Host " $Msg" -ForegroundColor DarkGray } +function Write-OK([string]$Msg) { Write-Host " OK $Msg" -ForegroundColor Green } +function Write-Warn([string]$Msg) { Write-Host " ! $Msg" -ForegroundColor Yellow } +function Write-Err([string]$Msg) { Write-Host " x $Msg" -ForegroundColor Red } + +function Prompt-YesNo([string]$Question, [bool]$Default = $true) { + $hint = if ($Default) { '[Y/n]' } else { '[y/N]' } + while ($true) { + $raw = (Read-Host " $Question $hint").Trim().ToLower() + if ($raw -eq '') { return $Default } + if ($raw -in 'y','yes') { return $true } + if ($raw -in 'n','no') { return $false } + } +} + +function Prompt-Text([string]$Question, [string]$Default = '') { + $suffix = if ($Default) { " [$Default]" } else { '' } + $raw = (Read-Host " $Question$suffix").Trim() + return if ($raw) { $raw } else { $Default } +} + +function Prompt-Int([string]$Question, [int]$Default) { + while ($true) { + $raw = Prompt-Text $Question "$Default" + if ($raw -match '^\d+$') { return [int]$raw } + Write-Err 'Please enter a number.' + } +} + +# --------------------------------------------------------------------------- +# Config +# --------------------------------------------------------------------------- +function Get-SyncConfig { + if (-not (Test-Path $CONFIG_FILE)) { + return @{ + local_root = '' + exiftool = '' + skip_online_only = $true + write_sidecars = $false + schedule_installed = $false + schedule_interval_minutes = 60 + } + } + $raw = Get-Content $CONFIG_FILE -Raw | ConvertFrom-Json + $ht = @{} + $raw.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value } + return $ht +} + +function Save-SyncConfig([hashtable]$Cfg) { + if (-not (Test-Path $CONFIG_DIR)) { + New-Item -ItemType Directory -Path $CONFIG_DIR | Out-Null + } + $Cfg | ConvertTo-Json | Set-Content $CONFIG_FILE -Encoding UTF8 +} + +# --------------------------------------------------------------------------- +# Token storage — DPAPI-encrypted file (no external dependencies) +# --------------------------------------------------------------------------- +function Save-SyncToken([string]$Tok) { + if (-not (Test-Path $CONFIG_DIR)) { + New-Item -ItemType Directory -Path $CONFIG_DIR | Out-Null + } + $bytes = [System.Text.Encoding]::UTF8.GetBytes($Tok) + $enc = [System.Security.Cryptography.ProtectedData]::Protect( + $bytes, $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser) + [System.IO.File]::WriteAllBytes((Join-Path $CONFIG_DIR 'token.dpapi'), $enc) +} + +function Load-SyncToken { + $path = Join-Path $CONFIG_DIR 'token.dpapi' + if (-not (Test-Path $path)) { return $null } + try { + $enc = [System.IO.File]::ReadAllBytes($path) + $dec = [System.Security.Cryptography.ProtectedData]::Unprotect( + $enc, $null, + [System.Security.Cryptography.DataProtectionScope]::CurrentUser) + return [System.Text.Encoding]::UTF8.GetString($dec) + } catch { return $null } +} + +function Clear-SyncToken { + $path = Join-Path $CONFIG_DIR 'token.dpapi' + if (Test-Path $path) { Remove-Item $path -Force } +} + +# --------------------------------------------------------------------------- +# Dropbox REST API helpers +# --------------------------------------------------------------------------- +function Invoke-Dropbox([string]$Url, [object]$Body, [string]$Tok) { + return Invoke-RestMethod -Uri $Url ` + -Method Post ` + -Headers @{ Authorization = "Bearer $Tok" } ` + -ContentType 'application/json' ` + -Body ($Body | ConvertTo-Json -Compress -Depth 10) +} + +function Test-DropboxToken([string]$Tok) { + $acct = Invoke-Dropbox 'https://api.dropboxapi.com/2/users/get_current_account' @{} $Tok + return @{ name = $acct.name.display_name; email = $acct.email } +} + +function Get-AllDropboxFiles([string]$Tok) { + $files = [System.Collections.Generic.List[object]]::new() + $resp = Invoke-Dropbox 'https://api.dropboxapi.com/2/files/list_folder' ` + @{ path = ''; recursive = $true; include_deleted = $false } $Tok + foreach ($e in $resp.entries) { if ($e.'.tag' -eq 'file') { $files.Add($e) } } + while ($resp.has_more) { + $resp = Invoke-Dropbox 'https://api.dropboxapi.com/2/files/list_folder/continue' ` + @{ cursor = $resp.cursor } $Tok + foreach ($e in $resp.entries) { if ($e.'.tag' -eq 'file') { $files.Add($e) } } + } + return $files +} + +function Get-DropboxTagBatches( + [System.Collections.Generic.List[object]]$Files, + [string]$Tok +) { + $byPath = @{} + foreach ($f in $Files) { $byPath[$f.path_lower] = $f } + $paths = @($byPath.Keys) + $results = [System.Collections.Generic.List[hashtable]]::new() + + for ($i = 0; $i -lt $paths.Count; $i += $TAG_BATCH) { + $end = [Math]::Min($i + $TAG_BATCH, $paths.Count) + $batch = $paths[$i..($end - 1)] + $resp = Invoke-Dropbox 'https://api.dropboxapi.com/2/files/tags/get' ` + @{ paths = $batch } $Tok + foreach ($entry in $resp.paths_to_tags) { + $tagNames = @( + $entry.tags | + Where-Object { $_.'.tag' -eq 'user_generated_tag' } | + ForEach-Object { $_.tag_text } | + Sort-Object + ) + $meta = $byPath[$entry.path] + if ($meta) { + $results.Add(@{ meta = $meta; tags = $tagNames }) + } + } + } + return $results +} + +# --------------------------------------------------------------------------- +# State (JSON hashtable: path_lower -> tags_csv) +# --------------------------------------------------------------------------- +function Open-SyncState { + if (-not (Test-Path $CONFIG_DIR)) { + New-Item -ItemType Directory -Path $CONFIG_DIR | Out-Null + } + if (Test-Path $STATE_FILE) { + $raw = Get-Content $STATE_FILE -Raw | ConvertFrom-Json + $ht = @{} + $raw.PSObject.Properties | ForEach-Object { $ht[$_.Name] = $_.Value } + return $ht + } + return @{} +} + +function Save-SyncState([hashtable]$St) { + $St | ConvertTo-Json -Compress | Set-Content $STATE_FILE -Encoding UTF8 +} + +# --------------------------------------------------------------------------- +# File helpers +# --------------------------------------------------------------------------- +$ATTR_RECALL = 0x00400000 # FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS +$ATTR_OFFLINE = 0x00001000 # FILE_ATTRIBUTE_OFFLINE + +function Test-OnlineOnly([string]$Path) { + try { + $attrs = [int](Get-Item $Path -Force).Attributes + return ($attrs -band ($ATTR_RECALL -bor $ATTR_OFFLINE)) -ne 0 + } catch { return $false } +} + +function Test-XmpWritable([string]$Ext) { + return $XMP_EXTS.Contains($Ext.ToLower()) +} + +function Invoke-ExifTool([string]$FilePath, [string[]]$Tags, [string]$ExifTool) { + $argList = @('-overwrite_original', '-q', '-XMP-dc:Subject=') + foreach ($t in $Tags) { $argList += "-XMP-dc:Subject+=$t" } + $argList += $FilePath + & $ExifTool @argList 2>&1 | Out-Null + if ($LASTEXITCODE -ne 0) { + throw "exiftool exited $LASTEXITCODE on: $FilePath" + } +} + +function Write-SidecarFile([string]$FilePath, [string[]]$Tags) { + $sidecar = "$FilePath.tags.json" + if ($Tags.Count -gt 0) { + @{ tags = $Tags } | ConvertTo-Json | Set-Content $sidecar -Encoding UTF8 + } elseif (Test-Path $sidecar) { + Remove-Item $sidecar -Force + } +} + +function Get-LocalPath([string]$PathDisplay, [string]$LocalRoot) { + $rel = $PathDisplay.TrimStart('/').Replace('/', '\') + return Join-Path $LocalRoot $rel +} + +# --------------------------------------------------------------------------- +# Auto-detection +# --------------------------------------------------------------------------- +function Find-DropboxFolder { + foreach ($base in @($env:LOCALAPPDATA, $env:APPDATA)) { + $info = Join-Path $base 'Dropbox\info.json' + if (Test-Path $info) { + try { + $j = Get-Content $info -Raw | ConvertFrom-Json + foreach ($key in @('personal','business')) { + $p = $j.$key.path + if ($p -and (Test-Path $p)) { return $p } + } + } catch {} + } + } + foreach ($guess in @( + "$env:USERPROFILE\Dropbox", + "C:\Users\$env:USERNAME\Dropbox" + )) { + if (Test-Path $guess) { return $guess } + } + return $null +} + +function Find-ExifTool { + $cmd = Get-Command 'exiftool.exe' -ErrorAction SilentlyContinue + if ($cmd) { return $cmd.Source } + foreach ($p in @('C:\Windows\exiftool.exe','C:\exiftool\exiftool.exe')) { + if (Test-Path $p) { return $p } + } + return $null +} + +# --------------------------------------------------------------------------- +# Scheduled task helpers +# --------------------------------------------------------------------------- +function Install-SyncTask([string]$ScriptPath, [int]$IntervalMinutes) { + $ps = (Get-Process -Id $PID).MainModule.FileName + $action = New-ScheduledTaskAction ` + -Execute $ps ` + -Argument "-NonInteractive -ExecutionPolicy Bypass -File `"$ScriptPath`" run" + $trigger = New-ScheduledTaskTrigger ` + -RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes) ` + -Once -At (Get-Date) + $set = New-ScheduledTaskSettingsSet ` + -ExecutionTimeLimit (New-TimeSpan -Hours 1) ` + -StartWhenAvailable + Register-ScheduledTask -TaskName $TASK_NAME -Action $action ` + -Trigger $trigger -Settings $set -RunLevel Limited -Force | Out-Null +} + +function Remove-SyncTask { + try { + Unregister-ScheduledTask -TaskName $TASK_NAME -Confirm:$false -ErrorAction Stop + return $true + } catch { return $false } +} + +function Get-SyncTaskStatus { + try { + $task = Get-ScheduledTask -TaskName $TASK_NAME -ErrorAction Stop + return @{ installed = $true; state = $task.State.ToString(); task_name = $TASK_NAME } + } catch { + return @{ installed = $false; task_name = $TASK_NAME } + } +} + +# --------------------------------------------------------------------------- +# Core sync loop +# --------------------------------------------------------------------------- +function Invoke-Sync { + param( + [string]$Tok, + [string]$LocalRoot, + [hashtable]$Cfg, + [bool]$DryRun = $false, + [bool]$SkipOnlineOnly = $true, + [scriptblock]$OnProgress = $null + ) + + $stats = @{ + listed = 0; applied = 0; cleared = 0; skipped = 0 + missing = 0; unsupported = 0; online_only = 0; errors = 0 + } + $st = Open-SyncState + $dirty = 0 + + Write-Info 'Listing Dropbox files...' + $files = Get-AllDropboxFiles $Tok + $stats.listed = $files.Count + Write-Info " $($files.Count) files found" + + Write-Info 'Fetching tags...' + $batches = Get-DropboxTagBatches $files $Tok + + foreach ($item in $batches) { + $meta = $item.meta + $tags = @($item.tags) + $tagsCsv = $tags -join ',' + $local = Get-LocalPath $meta.path_display $LocalRoot + $prevCsv = $st[$meta.path_lower] + + if ($prevCsv -eq $tagsCsv) { $stats.skipped++; continue } + if (-not $tags -and ($null -eq $prevCsv)) { $stats.skipped++; continue } + + if (-not (Test-Path $local)) { + $stats.missing++ + if ($OnProgress) { & $OnProgress $meta.path_display 'missing' $tags } + continue + } + if ($SkipOnlineOnly -and (Test-OnlineOnly $local)) { + $stats.online_only++ + if ($OnProgress) { & $OnProgress $meta.path_display 'online' $tags } + continue + } + + $ext = [System.IO.Path]::GetExtension($local) + $canXmp = Test-XmpWritable $ext + if (-not $canXmp -and -not $Cfg.write_sidecars) { + $stats.unsupported++ + if ($OnProgress) { & $OnProgress $meta.path_display 'unsupported' $tags } + continue + } + + if ($DryRun) { + if ($tags) { $stats.applied++ } else { $stats.cleared++ } + if ($OnProgress) { + & $OnProgress $meta.path_display (if ($tags) { 'applied' } else { 'cleared' }) $tags + } + continue + } + + try { + if ($canXmp) { Invoke-ExifTool $local $tags $Cfg.exiftool } + else { Write-SidecarFile $local $tags } + + $st[$meta.path_lower] = $tagsCsv + $dirty++ + # Flush state every 20 writes so partial runs aren't lost. + if ($dirty % 20 -eq 0) { Save-SyncState $st } + + if ($tags) { $stats.applied++ } else { $stats.cleared++ } + if ($OnProgress) { + & $OnProgress $meta.path_display (if ($tags) { 'applied' } else { 'cleared' }) $tags + } + } catch { + $stats.errors++ + Write-Warn "Failed on $($meta.path_display): $_" + if ($OnProgress) { & $OnProgress $meta.path_display 'error' $tags } + } + } + + if ($dirty -gt 0) { Save-SyncState $st } + return $stats +} + +# --------------------------------------------------------------------------- +# COMMAND: wizard +# --------------------------------------------------------------------------- +function Invoke-Wizard { + Write-Header 'Dropbox Tag Sync — Setup Wizard' + $existing = Get-SyncConfig + + # Step 1 — Dropbox folder + Write-Step '1/5' 'Locate your Dropbox folder' + $candidate = $null + if ($existing.local_root -and (Test-Path $existing.local_root)) { + $candidate = $existing.local_root + Write-Info "Previously configured: $candidate" + } else { + $candidate = Find-DropboxFolder + if ($candidate) { Write-Info "Auto-detected: $candidate" } + else { Write-Warn 'Could not auto-detect your Dropbox folder.' } + } + + $localRoot = $null + if ($candidate -and (Prompt-YesNo 'Is this the right folder?')) { + $localRoot = $candidate + } else { + while (-not $localRoot) { + $typed = Prompt-Text 'Enter the full path to your Dropbox folder' + if (-not $typed) { Write-Err 'Path is required.'; continue } + if (-not (Test-Path $typed)) { Write-Err "Not a directory: $typed"; continue } + $localRoot = $typed + } + } + + # Step 2 — ExifTool + Write-Step '2/5' 'Check ExifTool' + $exiftoolPath = Find-ExifTool + if (-not $exiftoolPath) { + Write-Err 'ExifTool not found. It is required to write Windows file tags.' + Write-Info 'Download from: https://exiftool.org/' + Write-Info 'Place exiftool.exe on your PATH or in C:\Windows\, then re-run the wizard.' + if (Prompt-YesNo 'Open the ExifTool download page now?') { + Start-Process 'https://exiftool.org/' + } + return 1 + } + Write-OK "ExifTool found: $exiftoolPath" + + # Step 3 — Token + Write-Step '3/5' 'Dropbox access token' + $existingTok = Load-SyncToken + $tok = $null + if ($existingTok) { + Write-Info 'A saved token was found.' + if (Prompt-YesNo 'Use the existing token?') { $tok = $existingTok } + } + if (-not $tok) { + Write-Info '' + Write-Info 'You need a Dropbox access token with read-only metadata scope.' + Write-Info ' 1. Go to https://www.dropbox.com/developers/apps' + Write-Info ' 2. Click "Create app" > Scoped access > Full Dropbox' + Write-Info ' 3. Permissions tab: enable files.metadata.read, click Submit' + Write-Info ' 4. Settings tab: Generated access token > Generate' + Write-Info ' 5. Copy and paste the token below' + if (Prompt-YesNo 'Open the Dropbox developer page now?') { + Start-Process 'https://www.dropbox.com/developers/apps' + } + $tok = (Read-Host ' Paste your access token').Trim() + if (-not $tok) { Write-Err 'No token provided.'; return 1 } + } + + Write-Info 'Verifying token...' + try { + $acct = Test-DropboxToken $tok + Write-OK "Signed in as $($acct.name) <$($acct.email)>" + } catch { + Write-Err "Token verification failed: $_" + return 1 + } + if ($tok -ne $existingTok) { + Save-SyncToken $tok + Write-Info 'Token stored (DPAPI-encrypted, current user only).' + } + + # Step 3b — Windows options + Write-Step '3b/5' 'Tagging options' + Write-Info 'Windows embeds tags inside files; only XMP-capable formats are supported.' + $skipOnline = Prompt-YesNo 'Skip online-only files (not yet downloaded)?' $existing.skip_online_only + $sidecars = Prompt-YesNo 'Write .tags.json sidecars for unsupported file types?' $existing.write_sidecars + + $cfg = @{ + local_root = $localRoot + exiftool = $exiftoolPath + skip_online_only = $skipOnline + write_sidecars = $sidecars + schedule_installed = $existing.schedule_installed + schedule_interval_minutes = $existing.schedule_interval_minutes + } + Save-SyncConfig $cfg + + # Step 4 — Preview + Write-Step '4/5' 'Scan Dropbox for tagged files' + Write-Info 'This may take a minute for large accounts...' + try { + $files = Get-AllDropboxFiles $tok + Write-Info " $($files.Count) files listed" + $batches = Get-DropboxTagBatches $files $tok + $tagged = @($batches | Where-Object { $_.tags.Count -gt 0 }) + Write-OK "Found $($tagged.Count) tagged file(s) across $($files.Count) total." + } catch { + Write-Err "Scan failed: $_"; return 1 + } + if ($tagged.Count -gt 0) { + Write-Info '' + Write-Info 'Sample:' + $tagged | Select-Object -First 5 | ForEach-Object { + Write-Dim " $($_.meta.path_display) -> $($_.tags -join ', ')" + } + if ($tagged.Count -gt 5) { Write-Dim " ... and $($tagged.Count - 5) more" } + } + + # Step 5 — Apply + schedule + Write-Step '5/5' 'Apply tags and schedule' + if ($tagged.Count -gt 0) { + Write-Warn 'Each tagged file will be modified in place; Dropbox will re-upload it once.' + if (Prompt-YesNo "Tag $($tagged.Count) file(s) now?") { + $stats = Invoke-Sync -Tok $tok -LocalRoot $localRoot -Cfg $cfg ` + -SkipOnlineOnly $skipOnline -DryRun $false + Write-Info "applied=$($stats.applied) cleared=$($stats.cleared) skipped=$($stats.skipped) errors=$($stats.errors)" + } + } else { + Write-Info 'Nothing to tag right now. Re-run later with: .\tagsync.ps1 run' + } + + if (Prompt-YesNo 'Install a scheduled task to sync periodically?') { + $mins = [Math]::Max(5, (Prompt-Int 'Run every how many minutes?' $cfg.schedule_interval_minutes)) + try { + Install-SyncTask $SCRIPT_PATH $mins + Write-OK "Scheduled task '$TASK_NAME' installed (every $mins min)." + $cfg.schedule_installed = $true + $cfg.schedule_interval_minutes = $mins + Save-SyncConfig $cfg + } catch { + Write-Err "Failed to install schedule: $_" + Write-Info 'You can try again later with: .\tagsync.ps1 schedule install' + } + } + + Write-Host '' + Write-OK 'Setup complete.' + Write-Info 'Re-run sync anytime: .\tagsync.ps1 run' + Write-Info 'Check status: .\tagsync.ps1 status' + return 0 +} + +# --------------------------------------------------------------------------- +# COMMAND: run +# --------------------------------------------------------------------------- +function Invoke-Run([bool]$DryRunMode, [bool]$VerboseMode) { + $cfg = Get-SyncConfig + $tok = Load-SyncToken + if (-not $cfg.local_root -or -not $tok) { + Write-Host 'Not configured. Run the wizard first: .\tagsync.ps1 wizard' -ForegroundColor Red + return 2 + } + if (-not (Test-Path $cfg.local_root)) { + Write-Host "Dropbox folder not found: $($cfg.local_root)" -ForegroundColor Red + return 2 + } + + $stats = Invoke-Sync -Tok $tok -LocalRoot $cfg.local_root -Cfg $cfg ` + -SkipOnlineOnly $cfg.skip_online_only -DryRun $DryRunMode ` + -OnProgress { + param($path, $action, $tags) + if ($VerboseMode) { + $color = switch ($action) { + 'applied' { 'Green' } + 'cleared' { 'Cyan' } + 'missing' { 'Yellow' } + 'error' { 'Red' } + default { 'DarkGray' } + } + Write-Host " [$action] $path" -ForegroundColor $color + } + } + + $prefix = if ($DryRunMode) { '(dry-run) ' } else { '' } + Write-Host "${prefix}done: applied=$($stats.applied) cleared=$($stats.cleared) " + + "skipped=$($stats.skipped) missing=$($stats.missing) " + + "unsupported=$($stats.unsupported) online-only=$($stats.online_only) errors=$($stats.errors)" + return if ($stats.errors -eq 0) { 0 } else { 1 } +} + +# --------------------------------------------------------------------------- +# COMMAND: status +# --------------------------------------------------------------------------- +function Invoke-Status { + $cfg = Get-SyncConfig + $tokOk = $null -ne (Load-SyncToken) + $sched = Get-SyncTaskStatus + + Write-Host 'Dropbox Tag Sync — status' + Write-Host ('-' * 40) + Write-Host "Configured: $(if ($cfg.local_root) { 'yes' } else { 'no (run wizard)' })" + Write-Host "Dropbox folder: $(if ($cfg.local_root) { $cfg.local_root } else { '(not set)' })" + Write-Host "Token stored: $(if ($tokOk) { 'yes' } else { 'no' })" + Write-Host "ExifTool: $(if ($cfg.exiftool) { $cfg.exiftool } else { '(not set)' })" + Write-Host "Skip online-only: $($cfg.skip_online_only)" + Write-Host "Sidecars: $($cfg.write_sidecars)" + Write-Host '' + Write-Host 'Schedule' + Write-Host " installed: $($sched.installed)" + if ($sched.installed) { Write-Host " state: $($sched.state)" } + Write-Host '' + Write-Host "Config: $CONFIG_FILE" + Write-Host "State: $STATE_FILE" + return 0 +} + +# --------------------------------------------------------------------------- +# COMMAND: schedule +# --------------------------------------------------------------------------- +function Invoke-Schedule([string]$ActionArg, [int]$IntervalArg) { + if (-not $ActionArg) { + Write-Host 'Usage: .\tagsync.ps1 schedule ' -ForegroundColor Red + return 2 + } + $cfg = Get-SyncConfig + switch ($ActionArg) { + 'install' { + $mins = if ($IntervalArg -gt 0) { $IntervalArg } ` + elseif ($cfg.schedule_interval_minutes -gt 0) { $cfg.schedule_interval_minutes } ` + else { 60 } + try { + Install-SyncTask $SCRIPT_PATH $mins + Write-Host "Installed scheduled task '$TASK_NAME' (every $mins minutes)." + $cfg.schedule_installed = $true + $cfg.schedule_interval_minutes = $mins + Save-SyncConfig $cfg + return 0 + } catch { + Write-Host "Failed: $_" -ForegroundColor Red; return 1 + } + } + 'uninstall' { + $removed = Remove-SyncTask + Write-Host (if ($removed) { 'Schedule removed.' } else { 'No schedule was installed.' }) + $cfg.schedule_installed = $false + Save-SyncConfig $cfg + return 0 + } + 'status' { + $s = Get-SyncTaskStatus + $s.GetEnumerator() | ForEach-Object { Write-Host "$($_.Key): $($_.Value)" } + return 0 + } + } +} + +# --------------------------------------------------------------------------- +# COMMAND: reset +# --------------------------------------------------------------------------- +function Invoke-Reset([bool]$Tok, [bool]$Cfg, [bool]$St, [bool]$Everything) { + if (-not ($Tok -or $Cfg -or $St -or $Everything)) { + Write-Host 'Nothing to do. Use -Token, -Settings, -State, or -All.' + return 2 + } + if ($Tok -or $Everything) { Clear-SyncToken; Write-Host 'Token cleared.' } + if ($Cfg -or $Everything) { + if (Test-Path $CONFIG_FILE) { Remove-Item $CONFIG_FILE -Force; Write-Host 'Settings cleared.' } + } + if ($St -or $Everything) { + if (Test-Path $STATE_FILE) { + Remove-Item $STATE_FILE -Force + Write-Host 'State cleared (next run will re-examine every file).' + } + } + return 0 +} + +# --------------------------------------------------------------------------- +# Entry point +# --------------------------------------------------------------------------- +if (-not $Command) { + Write-Host 'Usage: .\tagsync.ps1 [options]' + Write-Host '' + Write-Host 'Commands:' + Write-Host ' wizard Interactive setup (run this first)' + Write-Host ' run [-DryRun] [-Verbose] One-shot sync' + Write-Host ' status Show configuration and schedule state' + Write-Host ' schedule install [-Interval N] Install scheduled task (default: 60 min)' + Write-Host ' schedule uninstall Remove scheduled task' + Write-Host ' schedule status Show scheduled task state' + Write-Host ' reset [-Token] [-Settings] [-State] [-All]' + exit 0 +} + +$exitCode = switch ($Command) { + 'wizard' { Invoke-Wizard } + 'run' { Invoke-Run -DryRunMode $DryRun.IsPresent -VerboseMode ($PSBoundParameters['Verbose'] -eq $true) } + 'status' { Invoke-Status } + 'schedule' { Invoke-Schedule -ActionArg $Action -IntervalArg $Interval } + 'reset' { Invoke-Reset -Tok $Token.IsPresent -Cfg $Settings.IsPresent -St $State.IsPresent -Everything $All.IsPresent } + default { Write-Host "Unknown command: $Command" -ForegroundColor Red; 2 } +} +exit ($exitCode -as [int])