feat: add PowerShell port for Windows users without Python
CI / Tests (Linux, Python 3.12) (push) Successful in 9m24s
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:
@@ -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
@@ -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])
|
||||
Reference in New Issue
Block a user