feat: add PowerShell port for Windows users without Python
CI / Tests (Linux, Python 3.12) (push) Successful in 9m24s

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.
This commit is contained in:
2026-04-20 16:54:52 -04:00
parent 5e093cbf05
commit 24e0072c21
2 changed files with 890 additions and 99 deletions
+134 -99
View File
@@ -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>`.
| 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 <https://exiftool.org/>, 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>`.
| 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 <https://exiftool.org/>
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`).
+756
View File
@@ -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 <install|uninstall|status>' -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 <wizard|run|status|schedule|reset> [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])