feat: initial Dropbox tag sync tool
CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
CI / Tests (Linux, Python 3.12) (push) Failing after 4m51s
CI / Tests (Linux, Python 3.11) (push) Failing after 5m19s
CI / Tests (Linux, Python 3.9) (push) Failing after 5m3s
CI / Tests (macOS) (push) Has been cancelled
CI / Tests (Windows) (push) Has been cancelled
Cross-platform CLI that mirrors Dropbox web tags to OS-native tags: - macOS: Finder xattrs (content-preserving, no re-upload) - Windows: XMP:Subject via ExifTool (re-upload expected once per file) Includes an interactive wizard that auto-detects the OS and Dropbox folder, walks the user through read-only token creation, previews tagged files, applies tags, and installs a background scheduler (launchd on macOS, Task Scheduler on Windows). Ships with: - 47-test suite (pytest) covering config, state DB, run_sync, writers - Gitea Actions CI for Linux / macOS / Windows with coverage reporting
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
branches: [main]
|
||||
|
||||
jobs:
|
||||
test-linux:
|
||||
name: Tests (Linux, Python ${{ matrix.python-version }})
|
||||
runs-on: ubuntu-latest
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
python-version: ["3.9", "3.11", "3.12"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
cache: pip
|
||||
cache-dependency-path: |
|
||||
requirements.txt
|
||||
requirements-dev.txt
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
pip install -r requirements-dev.txt
|
||||
|
||||
- name: Syntax check
|
||||
run: |
|
||||
python -m py_compile tagsync.py
|
||||
python -c "import pathlib, py_compile; [py_compile.compile(str(p), doraise=True) for p in pathlib.Path('tagsync').rglob('*.py')]"
|
||||
|
||||
- name: CLI smoke test
|
||||
run: |
|
||||
python tagsync.py --help
|
||||
python tagsync.py status
|
||||
|
||||
- name: Run tests with coverage
|
||||
run: |
|
||||
pytest --cov=tagsync --cov-report=term-missing --cov-report=xml
|
||||
|
||||
- name: Upload coverage artifact
|
||||
if: matrix.python-version == '3.12'
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: coverage-xml
|
||||
path: coverage.xml
|
||||
retention-days: 14
|
||||
|
||||
test-macos:
|
||||
name: Tests (macOS)
|
||||
runs-on: macos-latest
|
||||
# macOS runners are more expensive — only run on main-branch pushes
|
||||
# and PRs that touch platform_macos.py or core logic.
|
||||
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'platform')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v
|
||||
|
||||
test-windows:
|
||||
name: Tests (Windows)
|
||||
runs-on: windows-latest
|
||||
if: github.event_name == 'push' || contains(github.event.pull_request.labels.*.name, 'platform')
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Install dependencies
|
||||
run: pip install -r requirements-dev.txt
|
||||
|
||||
- name: Run tests
|
||||
run: pytest -v
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
coverage.xml
|
||||
htmlcov/
|
||||
*.egg-info/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
venv/
|
||||
.env
|
||||
@@ -0,0 +1,197 @@
|
||||
# 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
|
||||
first-class local tags.
|
||||
|
||||
## The problem this solves
|
||||
|
||||
Dropbox lets you add **tags** to files from its web interface. Those tags
|
||||
live in Dropbox's cloud metadata — they are *not* stored inside the files
|
||||
themselves. The Dropbox desktop app syncs file **contents**, but it does
|
||||
not copy tags onto your local files. As a result, tags you rely on for
|
||||
search and organization show up on dropbox.com but are invisible to
|
||||
Finder, Spotlight, File Explorer, and Windows Search.
|
||||
|
||||
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
|
||||
|
||||
```
|
||||
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.
|
||||
|
||||
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
|
||||
|
||||
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 |
|
||||
|
||||
## 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).
|
||||
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
|
||||
only touch files whose tags actually changed.
|
||||
|
||||
Nothing is ever deleted from Dropbox. The tool only **reads** from
|
||||
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 — 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:
|
||||
|
||||
- Every file type can be tagged (text, zip, source code, anything).
|
||||
- The file's contents are untouched, so Dropbox does **not** see the
|
||||
file as modified and does **not** re-upload it.
|
||||
- The file's modification time does not change.
|
||||
|
||||
### Windows — embeds tags into the file
|
||||
|
||||
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:
|
||||
|
||||
- Only file formats that support XMP (images, PDFs, Office docs, most
|
||||
audio and video) can be tagged. The tool skips unsupported formats
|
||||
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.
|
||||
- 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.
|
||||
|
||||
## One-time Dropbox app setup
|
||||
|
||||
The wizard will open this page and walk you through it, but for
|
||||
reference:
|
||||
|
||||
1. Go to <https://www.dropbox.com/developers/apps> and click **Create app**.
|
||||
2. Choose:
|
||||
- **Scoped access**
|
||||
- **Full Dropbox**
|
||||
- A name of your choosing (e.g. `tag-sync`)
|
||||
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.
|
||||
|
||||
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`).
|
||||
- **Non-destructive on macOS**: only extended attributes are written.
|
||||
- **Content-modifying on Windows**: rewrites the XMP metadata section
|
||||
of supported files (one-time re-upload per tagged file is expected).
|
||||
- **Idempotent**: safe to run as often as you like.
|
||||
- **Resumable**: if interrupted, the next run picks up where it left off.
|
||||
- **One-way**: tags flow cloud → local only. Tagging on the web is
|
||||
still the source of truth.
|
||||
@@ -0,0 +1,27 @@
|
||||
[tool.pytest.ini_options]
|
||||
minversion = "7.0"
|
||||
testpaths = ["tests"]
|
||||
addopts = [
|
||||
"-ra",
|
||||
"--strict-markers",
|
||||
"--strict-config",
|
||||
]
|
||||
markers = [
|
||||
"macos: tests that only run on macOS",
|
||||
"windows: tests that only run on Windows",
|
||||
"needs_dropbox: tests that require the dropbox SDK installed",
|
||||
"needs_keyring: tests that require the keyring package installed",
|
||||
]
|
||||
|
||||
[tool.coverage.run]
|
||||
source = ["tagsync"]
|
||||
branch = true
|
||||
omit = ["tests/*"]
|
||||
|
||||
[tool.coverage.report]
|
||||
exclude_lines = [
|
||||
"pragma: no cover",
|
||||
"raise NotImplementedError",
|
||||
"if TYPE_CHECKING:",
|
||||
"if __name__ == .__main__.:",
|
||||
]
|
||||
@@ -0,0 +1,3 @@
|
||||
-r requirements.txt
|
||||
pytest>=7.0
|
||||
pytest-cov>=4.0
|
||||
@@ -0,0 +1,2 @@
|
||||
dropbox>=11.36.0
|
||||
keyring>=24.0.0
|
||||
+214
@@ -0,0 +1,214 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Dropbox Tag Sync — CLI entry point.
|
||||
|
||||
Subcommands:
|
||||
wizard Interactive setup (run this first)
|
||||
run Perform a one-shot sync using saved settings
|
||||
status Show current configuration and schedule state
|
||||
schedule Install/uninstall the background scheduled task
|
||||
reset Clear stored token and/or settings
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from tagsync import config, core, detect, wizard
|
||||
|
||||
ENTRY_SCRIPT = Path(__file__).resolve()
|
||||
|
||||
|
||||
def cmd_wizard(_args: argparse.Namespace) -> int:
|
||||
return wizard.run_wizard(entry_script=ENTRY_SCRIPT)
|
||||
|
||||
|
||||
def cmd_run(args: argparse.Namespace) -> int:
|
||||
logging.basicConfig(
|
||||
level=logging.DEBUG if args.verbose else logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
|
||||
settings = config.Settings.load()
|
||||
token = config.load_token()
|
||||
|
||||
if not settings.local_root or not token:
|
||||
print(
|
||||
"Not configured yet. Run the wizard first:\n"
|
||||
f" {sys.executable} {ENTRY_SCRIPT} wizard",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
local_root = Path(settings.local_root)
|
||||
if not local_root.is_dir():
|
||||
print(
|
||||
f"Configured Dropbox folder no longer exists: {local_root}\n"
|
||||
"Re-run the wizard to update it.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
writer = wizard._build_writer(settings)
|
||||
stats = core.run_sync(
|
||||
token=token,
|
||||
local_root=local_root,
|
||||
state_db=config.STATE_DB_FILE,
|
||||
writer=writer,
|
||||
skip_online_only=settings.skip_online_only,
|
||||
dry_run=args.dry_run,
|
||||
)
|
||||
print(f"done: {stats.summary()}")
|
||||
return 0 if stats.errors == 0 else 1
|
||||
|
||||
|
||||
def cmd_status(_args: argparse.Namespace) -> int:
|
||||
settings = config.Settings.load()
|
||||
token_present = config.load_token() is not None
|
||||
|
||||
print("Dropbox Tag Sync — status")
|
||||
print("-" * 40)
|
||||
print(f"Configured: {'yes' if settings.local_root else 'no (run wizard)'}")
|
||||
print(f"Platform: {settings.platform or detect.detect_platform()}")
|
||||
print(f"Dropbox folder: {settings.local_root or '(not set)'}")
|
||||
print(f"Token stored: {'yes' if token_present else 'no'}")
|
||||
if settings.platform == "windows":
|
||||
print(f"ExifTool: {settings.exiftool or '(not set)'}")
|
||||
print(f"Skip online-only:{settings.skip_online_only}")
|
||||
print(f"Sidecars: {settings.write_sidecars}")
|
||||
|
||||
sched = _schedule_status(settings.platform)
|
||||
print()
|
||||
print("Schedule")
|
||||
for key, val in sched.items():
|
||||
print(f" {key}: {val}")
|
||||
|
||||
print()
|
||||
print(f"State DB: {config.STATE_DB_FILE}")
|
||||
return 0
|
||||
|
||||
|
||||
def cmd_schedule(args: argparse.Namespace) -> int:
|
||||
settings = config.Settings.load()
|
||||
plat = settings.platform or detect.detect_platform()
|
||||
|
||||
if args.action == "install":
|
||||
interval = args.interval or settings.schedule_interval_minutes or 60
|
||||
try:
|
||||
if plat == "macos":
|
||||
from tagsync import platform_macos
|
||||
plist = platform_macos.install_schedule(ENTRY_SCRIPT, interval_minutes=interval)
|
||||
print(f"Installed launchd agent: {plist}")
|
||||
elif plat == "windows":
|
||||
from tagsync import platform_windows
|
||||
platform_windows.install_schedule(ENTRY_SCRIPT, interval_minutes=interval)
|
||||
print(f"Installed scheduled task: {platform_windows.TASK_NAME}")
|
||||
else:
|
||||
print(f"Unsupported platform: {plat}", file=sys.stderr)
|
||||
return 2
|
||||
except Exception as e:
|
||||
print(f"Schedule install failed: {e}", file=sys.stderr)
|
||||
return 1
|
||||
settings.schedule_installed = True
|
||||
settings.schedule_interval_minutes = interval
|
||||
settings.save()
|
||||
return 0
|
||||
|
||||
if args.action == "uninstall":
|
||||
if plat == "macos":
|
||||
from tagsync import platform_macos
|
||||
removed = platform_macos.uninstall_schedule()
|
||||
elif plat == "windows":
|
||||
from tagsync import platform_windows
|
||||
removed = platform_windows.uninstall_schedule()
|
||||
else:
|
||||
print(f"Unsupported platform: {plat}", file=sys.stderr)
|
||||
return 2
|
||||
print("Schedule removed." if removed else "No schedule was installed.")
|
||||
settings.schedule_installed = False
|
||||
settings.save()
|
||||
return 0
|
||||
|
||||
if args.action == "status":
|
||||
sched = _schedule_status(plat)
|
||||
for key, val in sched.items():
|
||||
print(f"{key}: {val}")
|
||||
return 0
|
||||
|
||||
return 2
|
||||
|
||||
|
||||
def cmd_reset(args: argparse.Namespace) -> int:
|
||||
if args.token or args.all:
|
||||
config.clear_token()
|
||||
print("Token cleared.")
|
||||
if args.settings or args.all:
|
||||
if config.CONFIG_FILE.exists():
|
||||
config.CONFIG_FILE.unlink()
|
||||
print("Settings cleared.")
|
||||
if args.state or args.all:
|
||||
if config.STATE_DB_FILE.exists():
|
||||
config.STATE_DB_FILE.unlink()
|
||||
print("State DB cleared (next run will re-examine every file).")
|
||||
if not (args.token or args.settings or args.state or args.all):
|
||||
print("Nothing to do. Use --token, --settings, --state, or --all.")
|
||||
return 2
|
||||
return 0
|
||||
|
||||
|
||||
def _schedule_status(plat: str) -> dict:
|
||||
try:
|
||||
if plat == "macos":
|
||||
from tagsync import platform_macos
|
||||
return platform_macos.schedule_status()
|
||||
if plat == "windows":
|
||||
from tagsync import platform_windows
|
||||
return platform_windows.schedule_status()
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
return {"error": f"unsupported platform: {plat}"}
|
||||
|
||||
|
||||
def build_parser() -> argparse.ArgumentParser:
|
||||
p = argparse.ArgumentParser(
|
||||
prog="tagsync",
|
||||
description="Sync Dropbox web tags to local OS-native tags.",
|
||||
)
|
||||
sub = p.add_subparsers(dest="cmd", required=True)
|
||||
|
||||
w = sub.add_parser("wizard", help="Interactive setup (run this first)")
|
||||
w.set_defaults(func=cmd_wizard)
|
||||
|
||||
r = sub.add_parser("run", help="Perform a one-shot sync")
|
||||
r.add_argument("--dry-run", action="store_true",
|
||||
help="Report what would happen without writing tags")
|
||||
r.add_argument("--verbose", action="store_true")
|
||||
r.set_defaults(func=cmd_run)
|
||||
|
||||
s = sub.add_parser("status", help="Show current configuration and schedule state")
|
||||
s.set_defaults(func=cmd_status)
|
||||
|
||||
sch = sub.add_parser("schedule", help="Manage the background scheduled task")
|
||||
sch.add_argument("action", choices=["install", "uninstall", "status"])
|
||||
sch.add_argument("--interval", type=int, default=0,
|
||||
help="Minutes between runs (install only, default 60)")
|
||||
sch.set_defaults(func=cmd_schedule)
|
||||
|
||||
rs = sub.add_parser("reset", help="Clear stored token, settings, or state")
|
||||
rs.add_argument("--token", action="store_true")
|
||||
rs.add_argument("--settings", action="store_true")
|
||||
rs.add_argument("--state", action="store_true")
|
||||
rs.add_argument("--all", action="store_true")
|
||||
rs.set_defaults(func=cmd_reset)
|
||||
|
||||
return p
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = build_parser().parse_args(argv)
|
||||
return args.func(args)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,3 @@
|
||||
"""Dropbox tag sync: mirror Dropbox web tags to local OS-native tags."""
|
||||
|
||||
__version__ = "0.1.0"
|
||||
@@ -0,0 +1,94 @@
|
||||
"""Settings file + secure token storage."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
|
||||
CONFIG_DIR = Path.home() / ".dropbox_tag_sync"
|
||||
CONFIG_FILE = CONFIG_DIR / "config.json"
|
||||
STATE_DB_FILE = CONFIG_DIR / "state.db"
|
||||
TOKEN_FALLBACK_FILE = CONFIG_DIR / "token"
|
||||
|
||||
KEYRING_SERVICE = "dropbox_tag_sync"
|
||||
KEYRING_USER = "default"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Settings:
|
||||
"""Persisted per-user settings."""
|
||||
|
||||
local_root: str = ""
|
||||
platform: str = "" # "macos" or "windows"
|
||||
exiftool: str = "" # windows only
|
||||
skip_online_only: bool = True # windows only
|
||||
write_sidecars: bool = False # windows only
|
||||
schedule_installed: bool = False
|
||||
schedule_interval_minutes: int = 60
|
||||
|
||||
def save(self, path: Path = CONFIG_FILE) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(asdict(self), indent=2))
|
||||
|
||||
@classmethod
|
||||
def load(cls, path: Path = CONFIG_FILE) -> "Settings":
|
||||
if not path.exists():
|
||||
return cls()
|
||||
data = json.loads(path.read_text())
|
||||
# Filter to known fields so old configs don't crash on unknown keys.
|
||||
known = {f for f in cls.__dataclass_fields__}
|
||||
return cls(**{k: v for k, v in data.items() if k in known})
|
||||
|
||||
|
||||
# --- Token storage ------------------------------------------------------------
|
||||
#
|
||||
# Primary: OS keyring (macOS Keychain, Windows Credential Manager).
|
||||
# Fallback: ~/.dropbox_tag_sync/token (mode 0600). Used only if keyring
|
||||
# import fails for some reason.
|
||||
|
||||
|
||||
def _keyring_available() -> bool:
|
||||
try:
|
||||
import keyring # noqa: F401
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def save_token(token: str) -> str:
|
||||
"""Persist token. Returns a human-readable location description."""
|
||||
if _keyring_available():
|
||||
import keyring
|
||||
keyring.set_password(KEYRING_SERVICE, KEYRING_USER, token)
|
||||
return "system keyring"
|
||||
TOKEN_FALLBACK_FILE.parent.mkdir(parents=True, exist_ok=True)
|
||||
TOKEN_FALLBACK_FILE.write_text(token)
|
||||
try:
|
||||
os.chmod(TOKEN_FALLBACK_FILE, stat.S_IRUSR | stat.S_IWUSR)
|
||||
except OSError:
|
||||
pass
|
||||
return f"plaintext file at {TOKEN_FALLBACK_FILE} (keyring unavailable)"
|
||||
|
||||
|
||||
def load_token() -> str | None:
|
||||
if _keyring_available():
|
||||
import keyring
|
||||
tok = keyring.get_password(KEYRING_SERVICE, KEYRING_USER)
|
||||
if tok:
|
||||
return tok
|
||||
if TOKEN_FALLBACK_FILE.exists():
|
||||
return TOKEN_FALLBACK_FILE.read_text().strip() or None
|
||||
return None
|
||||
|
||||
|
||||
def clear_token() -> None:
|
||||
if _keyring_available():
|
||||
import keyring
|
||||
try:
|
||||
keyring.delete_password(KEYRING_SERVICE, KEYRING_USER)
|
||||
except Exception:
|
||||
pass
|
||||
if TOKEN_FALLBACK_FILE.exists():
|
||||
TOKEN_FALLBACK_FILE.unlink()
|
||||
+267
@@ -0,0 +1,267 @@
|
||||
"""Shared Dropbox listing, tag fetching, state DB, and sync loop.
|
||||
|
||||
Platform-agnostic. The sync loop takes a `Writer` object that knows how to
|
||||
write tags on the current OS (see `platform_macos.py` / `platform_windows.py`).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Callable, Iterator, Protocol
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import dropbox
|
||||
from dropbox.files import FileMetadata
|
||||
|
||||
TAG_BATCH = 100 # Dropbox files/tags/get accepts up to 100 paths per call.
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# --- Data shapes --------------------------------------------------------------
|
||||
|
||||
|
||||
@dataclass
|
||||
class TaggedFile:
|
||||
path_display: str # case-preserving Dropbox path (e.g. /Photos/IMG.jpg)
|
||||
path_lower: str # lowercase Dropbox path (stable key)
|
||||
local_path: Path
|
||||
tags: list[str] # sorted tag names
|
||||
|
||||
|
||||
@dataclass
|
||||
class SyncStats:
|
||||
listed: int = 0
|
||||
applied: int = 0
|
||||
cleared: int = 0
|
||||
skipped: int = 0
|
||||
missing: int = 0
|
||||
unsupported: int = 0
|
||||
online_only: int = 0
|
||||
errors: int = 0
|
||||
|
||||
def summary(self) -> str:
|
||||
return (
|
||||
f"applied={self.applied} cleared={self.cleared} "
|
||||
f"skipped={self.skipped} missing={self.missing} "
|
||||
f"unsupported={self.unsupported} online-only={self.online_only} "
|
||||
f"errors={self.errors}"
|
||||
)
|
||||
|
||||
|
||||
class Writer(Protocol):
|
||||
"""Platform-specific tag writer."""
|
||||
|
||||
def supports(self, path: Path) -> bool: ...
|
||||
def is_online_only(self, path: Path) -> bool: ...
|
||||
def write_tags(self, path: Path, tags: list[str]) -> None: ...
|
||||
|
||||
|
||||
# --- Dropbox auth + listing ---------------------------------------------------
|
||||
|
||||
|
||||
def _dropbox_client(token: str):
|
||||
import dropbox
|
||||
return dropbox.Dropbox(token)
|
||||
|
||||
|
||||
def verify_token(token: str) -> dict:
|
||||
"""Validate a token and return account info. Raises on failure."""
|
||||
dbx = _dropbox_client(token)
|
||||
account = dbx.users_get_current_account()
|
||||
return {
|
||||
"name": account.name.display_name,
|
||||
"email": account.email,
|
||||
"account_id": account.account_id,
|
||||
}
|
||||
|
||||
|
||||
def _list_all_files(dbx) -> list:
|
||||
from dropbox.files import FileMetadata
|
||||
files: list = []
|
||||
result = dbx.files_list_folder("", recursive=True, include_deleted=False)
|
||||
files.extend(e for e in result.entries if isinstance(e, FileMetadata))
|
||||
while result.has_more:
|
||||
result = dbx.files_list_folder_continue(result.cursor)
|
||||
files.extend(e for e in result.entries if isinstance(e, FileMetadata))
|
||||
return files
|
||||
|
||||
|
||||
def _fetch_tags(dbx, files: list) -> Iterator[tuple]:
|
||||
by_path = {f.path_lower: f for f in files}
|
||||
paths = list(by_path.keys())
|
||||
for i in range(0, len(paths), TAG_BATCH):
|
||||
batch = paths[i : i + TAG_BATCH]
|
||||
resp = dbx.files_tags_get(batch)
|
||||
for entry in resp.paths_to_tags:
|
||||
tag_names = sorted(
|
||||
t.get_user_generated_tag().tag_text for t in entry.tags
|
||||
)
|
||||
meta = by_path.get(entry.path)
|
||||
if meta is not None:
|
||||
yield meta, tag_names
|
||||
|
||||
|
||||
def _local_path(path_display: str, local_root: Path) -> Path:
|
||||
return local_root / path_display.lstrip("/").replace("/", os.sep)
|
||||
|
||||
|
||||
def collect_tagged_files(
|
||||
token: str,
|
||||
local_root: Path,
|
||||
on_list_progress: Callable[[int], None] | None = None,
|
||||
on_tag_progress: Callable[[int, int], None] | None = None,
|
||||
) -> list[TaggedFile]:
|
||||
"""Return every Dropbox file that currently has at least one tag.
|
||||
|
||||
Used by the wizard preview step. Does not include untagged files.
|
||||
"""
|
||||
dbx = _dropbox_client(token)
|
||||
files = _list_all_files(dbx)
|
||||
if on_list_progress:
|
||||
on_list_progress(len(files))
|
||||
tagged: list[TaggedFile] = []
|
||||
total = len(files)
|
||||
done = 0
|
||||
for meta, tags in _fetch_tags(dbx, files):
|
||||
done += 1
|
||||
if on_tag_progress and done % TAG_BATCH == 0:
|
||||
on_tag_progress(done, total)
|
||||
if tags:
|
||||
tagged.append(
|
||||
TaggedFile(
|
||||
path_display=meta.path_display,
|
||||
path_lower=meta.path_lower,
|
||||
local_path=_local_path(meta.path_display, local_root),
|
||||
tags=tags,
|
||||
)
|
||||
)
|
||||
if on_tag_progress:
|
||||
on_tag_progress(done, total)
|
||||
return tagged
|
||||
|
||||
|
||||
# --- State DB -----------------------------------------------------------------
|
||||
|
||||
|
||||
def open_state(db_path: Path) -> sqlite3.Connection:
|
||||
db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS file_tags (
|
||||
path_lower TEXT PRIMARY KEY,
|
||||
tags_csv TEXT NOT NULL,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
return conn
|
||||
|
||||
|
||||
# --- Main sync loop -----------------------------------------------------------
|
||||
|
||||
|
||||
def run_sync(
|
||||
token: str,
|
||||
local_root: Path,
|
||||
state_db: Path,
|
||||
writer: Writer,
|
||||
*,
|
||||
skip_online_only: bool = False,
|
||||
dry_run: bool = False,
|
||||
on_progress: Callable[[TaggedFile, str], None] | None = None,
|
||||
) -> SyncStats:
|
||||
"""Full one-shot sync: pull tags from Dropbox, write to local files.
|
||||
|
||||
`on_progress(tagged_file, action)` is called for each file that is
|
||||
actually touched. `action` is one of: 'applied', 'cleared', 'skipped',
|
||||
'missing', 'unsupported', 'online', 'error'.
|
||||
"""
|
||||
dbx = _dropbox_client(token)
|
||||
state = open_state(state_db)
|
||||
stats = SyncStats()
|
||||
|
||||
files = _list_all_files(dbx)
|
||||
stats.listed = len(files)
|
||||
|
||||
for meta, tags in _fetch_tags(dbx, files):
|
||||
local = _local_path(meta.path_display, local_root)
|
||||
tags_csv = ",".join(tags)
|
||||
tf = TaggedFile(
|
||||
path_display=meta.path_display,
|
||||
path_lower=meta.path_lower,
|
||||
local_path=local,
|
||||
tags=tags,
|
||||
)
|
||||
|
||||
row = state.execute(
|
||||
"SELECT tags_csv FROM file_tags WHERE path_lower = ?",
|
||||
(meta.path_lower,),
|
||||
).fetchone()
|
||||
|
||||
if row and row[0] == tags_csv:
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
if not tags and not row:
|
||||
# Untagged file we've never tagged — nothing to do.
|
||||
stats.skipped += 1
|
||||
continue
|
||||
|
||||
if not local.exists():
|
||||
log.warning("not found locally: %s", local)
|
||||
stats.missing += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "missing")
|
||||
continue
|
||||
|
||||
if skip_online_only and writer.is_online_only(local):
|
||||
stats.online_only += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "online")
|
||||
continue
|
||||
|
||||
if not writer.supports(local):
|
||||
stats.unsupported += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "unsupported")
|
||||
continue
|
||||
|
||||
if dry_run:
|
||||
if tags:
|
||||
stats.applied += 1
|
||||
else:
|
||||
stats.cleared += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "applied" if tags else "cleared")
|
||||
continue
|
||||
|
||||
try:
|
||||
writer.write_tags(local, tags)
|
||||
state.execute(
|
||||
"INSERT INTO file_tags (path_lower, tags_csv) VALUES (?, ?) "
|
||||
"ON CONFLICT(path_lower) DO UPDATE SET "
|
||||
" tags_csv = excluded.tags_csv, "
|
||||
" updated_at = CURRENT_TIMESTAMP",
|
||||
(meta.path_lower, tags_csv),
|
||||
)
|
||||
state.commit()
|
||||
if tags:
|
||||
stats.applied += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "applied")
|
||||
else:
|
||||
stats.cleared += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "cleared")
|
||||
except Exception as e:
|
||||
log.error("tagging failed on %s: %s", local, e)
|
||||
stats.errors += 1
|
||||
if on_progress:
|
||||
on_progress(tf, "error")
|
||||
|
||||
return stats
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Environment detection: platform, Dropbox folder, dependencies."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
Platform = Literal["macos", "windows", "linux"]
|
||||
|
||||
|
||||
def detect_platform() -> Platform:
|
||||
"""Return 'macos', 'windows', or 'linux'."""
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return "macos"
|
||||
if system == "Windows":
|
||||
return "windows"
|
||||
return "linux"
|
||||
|
||||
|
||||
def platform_label() -> str:
|
||||
"""Human-friendly OS label, e.g. 'macOS 14.4'."""
|
||||
system = platform.system()
|
||||
if system == "Darwin":
|
||||
return f"macOS {platform.mac_ver()[0]}"
|
||||
if system == "Windows":
|
||||
return f"Windows {platform.release()}"
|
||||
return f"{system} {platform.release()}"
|
||||
|
||||
|
||||
def python_label() -> str:
|
||||
return f"Python {sys.version.split()[0]}"
|
||||
|
||||
|
||||
def _dropbox_info_candidates() -> list[Path]:
|
||||
"""Candidate paths for Dropbox's info.json config file."""
|
||||
home = Path.home()
|
||||
paths: list[Path] = []
|
||||
if sys.platform == "win32":
|
||||
for env_var in ("APPDATA", "LOCALAPPDATA"):
|
||||
val = os.environ.get(env_var)
|
||||
if val:
|
||||
paths.append(Path(val) / "Dropbox" / "info.json")
|
||||
else:
|
||||
paths.append(home / ".dropbox" / "info.json")
|
||||
paths.append(
|
||||
home / "Library" / "Application Support" / "Dropbox" / "info.json"
|
||||
)
|
||||
return paths
|
||||
|
||||
|
||||
def detect_dropbox_folder() -> Path | None:
|
||||
"""Find the local Dropbox sync folder.
|
||||
|
||||
First tries Dropbox's own info.json (authoritative), then falls back to
|
||||
common default locations.
|
||||
"""
|
||||
for info_path in _dropbox_info_candidates():
|
||||
if not info_path.exists():
|
||||
continue
|
||||
try:
|
||||
info = json.loads(info_path.read_text())
|
||||
except (OSError, json.JSONDecodeError):
|
||||
continue
|
||||
for account_type in ("personal", "business"):
|
||||
account = info.get(account_type)
|
||||
if account and "path" in account:
|
||||
candidate = Path(account["path"])
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
home = Path.home()
|
||||
for candidate in (home / "Dropbox", home / "Dropbox (Personal)"):
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def has_module(module_name: str) -> bool:
|
||||
"""True if a Python module is importable without actually importing it."""
|
||||
return importlib.util.find_spec(module_name) is not None
|
||||
|
||||
|
||||
def find_exiftool() -> Path | None:
|
||||
"""Locate exiftool on PATH, if present."""
|
||||
for name in ("exiftool", "exiftool.exe"):
|
||||
found = shutil.which(name)
|
||||
if found:
|
||||
return Path(found)
|
||||
return None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""macOS tag writer (Finder xattr) + launchd scheduler."""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import os
|
||||
import plistlib
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from textwrap import dedent
|
||||
|
||||
FINDER_TAGS_XATTR = "com.apple.metadata:_kMDItemUserTags"
|
||||
LAUNCHD_LABEL = "com.dropboxtagsync.agent"
|
||||
LAUNCHD_PLIST = Path.home() / "Library" / "LaunchAgents" / f"{LAUNCHD_LABEL}.plist"
|
||||
LOG_PATH = Path.home() / "Library" / "Logs" / "dropbox-tag-sync.log"
|
||||
|
||||
# Errno returned by macOS when an xattr is missing (ENOATTR = 93).
|
||||
_NO_ATTR_ERRNOS = {93, errno.ENODATA if hasattr(errno, "ENODATA") else 61}
|
||||
|
||||
|
||||
class MacWriter:
|
||||
"""Writer that stores tags in the Finder xattr. All file types supported."""
|
||||
|
||||
def supports(self, path: Path) -> bool:
|
||||
return True
|
||||
|
||||
def is_online_only(self, path: Path) -> bool:
|
||||
# macOS Dropbox doesn't use a public placeholder attribute equivalent
|
||||
# to Windows. If this becomes a concern we can add detection via
|
||||
# Dropbox's File Provider APIs, but for now assume files are materialized.
|
||||
return False
|
||||
|
||||
def write_tags(self, path: Path, tags: list[str]) -> None:
|
||||
if not tags:
|
||||
try:
|
||||
os.removexattr(path, FINDER_TAGS_XATTR)
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as e:
|
||||
if e.errno not in _NO_ATTR_ERRNOS:
|
||||
raise
|
||||
return
|
||||
plist = plistlib.dumps(tags, fmt=plistlib.FMT_BINARY)
|
||||
os.setxattr(path, FINDER_TAGS_XATTR, plist)
|
||||
|
||||
|
||||
def install_schedule(
|
||||
entry_script: Path,
|
||||
interval_minutes: int = 60,
|
||||
python_exe: str | None = None,
|
||||
) -> Path:
|
||||
"""Create and load a launchd agent that runs the sync every N minutes."""
|
||||
python_exe = python_exe or sys.executable
|
||||
interval_seconds = max(60, interval_minutes * 60)
|
||||
|
||||
plist_body = dedent(
|
||||
f"""\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
|
||||
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0"><dict>
|
||||
<key>Label</key><string>{LAUNCHD_LABEL}</string>
|
||||
<key>ProgramArguments</key><array>
|
||||
<string>{python_exe}</string>
|
||||
<string>{entry_script}</string>
|
||||
<string>run</string>
|
||||
</array>
|
||||
<key>StartInterval</key><integer>{interval_seconds}</integer>
|
||||
<key>RunAtLoad</key><false/>
|
||||
<key>StandardOutPath</key><string>{LOG_PATH}</string>
|
||||
<key>StandardErrorPath</key><string>{LOG_PATH}</string>
|
||||
</dict></plist>
|
||||
"""
|
||||
)
|
||||
|
||||
LAUNCHD_PLIST.parent.mkdir(parents=True, exist_ok=True)
|
||||
LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
|
||||
LAUNCHD_PLIST.write_text(plist_body)
|
||||
|
||||
# Unload any prior version, then load the new one.
|
||||
subprocess.run(
|
||||
["launchctl", "unload", str(LAUNCHD_PLIST)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
result = subprocess.run(
|
||||
["launchctl", "load", str(LAUNCHD_PLIST)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"launchctl load failed: {result.stderr.strip()}")
|
||||
return LAUNCHD_PLIST
|
||||
|
||||
|
||||
def uninstall_schedule() -> bool:
|
||||
"""Remove the launchd agent. Returns True if it existed."""
|
||||
if not LAUNCHD_PLIST.exists():
|
||||
return False
|
||||
subprocess.run(
|
||||
["launchctl", "unload", str(LAUNCHD_PLIST)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
LAUNCHD_PLIST.unlink()
|
||||
return True
|
||||
|
||||
|
||||
def schedule_status() -> dict:
|
||||
"""Return info about whether the agent is installed/loaded."""
|
||||
installed = LAUNCHD_PLIST.exists()
|
||||
loaded = False
|
||||
if installed:
|
||||
result = subprocess.run(
|
||||
["launchctl", "list", LAUNCHD_LABEL],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
loaded = result.returncode == 0
|
||||
return {
|
||||
"installed": installed,
|
||||
"loaded": loaded,
|
||||
"plist": str(LAUNCHD_PLIST),
|
||||
"log": str(LOG_PATH),
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
"""Windows tag writer (XMP via exiftool) + Task Scheduler."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
TASK_NAME = "DropboxTagSync"
|
||||
|
||||
# Windows file attribute flags identifying cloud placeholders.
|
||||
FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS = 0x00400000
|
||||
FILE_ATTRIBUTE_OFFLINE = 0x00001000
|
||||
|
||||
# Conservative list of extensions exiftool can reliably write XMP into.
|
||||
# Full list: https://exiftool.org/#supported (see "Write" column).
|
||||
XMP_WRITABLE_EXTS: set[str] = {
|
||||
# Images
|
||||
".jpg", ".jpeg", ".jpe", ".jfif", ".png", ".tif", ".tiff", ".gif",
|
||||
".heic", ".heif", ".webp", ".psd", ".dng", ".cr2", ".cr3", ".nef",
|
||||
".arw", ".orf", ".raf", ".rw2", ".svg",
|
||||
# Documents
|
||||
".pdf", ".docx", ".xlsx", ".pptx", ".odt", ".ods", ".odp", ".epub",
|
||||
# Audio / video
|
||||
".mp3", ".m4a", ".wav", ".flac", ".aac", ".ogg",
|
||||
".mp4", ".m4v", ".mov", ".avi", ".mkv",
|
||||
}
|
||||
|
||||
|
||||
class WindowsWriter:
|
||||
"""Writes Explorer 'Tags' via XMP:Subject using exiftool.
|
||||
|
||||
Can optionally write a .tags.json sidecar for unsupported file types.
|
||||
"""
|
||||
|
||||
def __init__(self, exiftool: str, write_sidecars: bool = False) -> None:
|
||||
self.exiftool = exiftool
|
||||
self.write_sidecars = write_sidecars
|
||||
|
||||
def supports(self, path: Path) -> bool:
|
||||
if path.suffix.lower() in XMP_WRITABLE_EXTS:
|
||||
return True
|
||||
return self.write_sidecars
|
||||
|
||||
def is_online_only(self, path: Path) -> bool:
|
||||
try:
|
||||
attrs = path.stat().st_file_attributes # type: ignore[attr-defined]
|
||||
except (AttributeError, OSError):
|
||||
return False
|
||||
return bool(
|
||||
attrs & (FILE_ATTRIBUTE_RECALL_ON_DATA_ACCESS | FILE_ATTRIBUTE_OFFLINE)
|
||||
)
|
||||
|
||||
def write_tags(self, path: Path, tags: list[str]) -> None:
|
||||
if path.suffix.lower() in XMP_WRITABLE_EXTS:
|
||||
self._write_xmp(path, tags)
|
||||
elif self.write_sidecars:
|
||||
self._write_sidecar(path, tags)
|
||||
else:
|
||||
raise RuntimeError(f"unsupported file type for tagging: {path.suffix}")
|
||||
|
||||
def _write_xmp(self, path: Path, tags: list[str]) -> None:
|
||||
args = [self.exiftool, "-overwrite_original", "-q", "-XMP-dc:Subject="]
|
||||
for tag in tags:
|
||||
args.append(f"-XMP-dc:Subject+={tag}")
|
||||
args.append(str(path))
|
||||
result = subprocess.run(args, capture_output=True, text=True)
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"exiftool failed: {result.stderr.strip()}")
|
||||
|
||||
@staticmethod
|
||||
def _write_sidecar(path: Path, tags: list[str]) -> None:
|
||||
sidecar = path.with_suffix(path.suffix + ".tags.json")
|
||||
if tags:
|
||||
sidecar.write_text(json.dumps({"tags": tags}, indent=2))
|
||||
else:
|
||||
try:
|
||||
sidecar.unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
|
||||
|
||||
def install_schedule(
|
||||
entry_script: Path,
|
||||
interval_minutes: int = 60,
|
||||
python_exe: str | None = None,
|
||||
) -> None:
|
||||
"""Create a recurring Windows scheduled task."""
|
||||
python_exe = python_exe or sys.executable
|
||||
# Command is quoted as a single string since /tr takes one argument.
|
||||
task_cmd = f'"{python_exe}" "{entry_script}" run'
|
||||
subprocess.run(
|
||||
[
|
||||
"schtasks", "/create",
|
||||
"/tn", TASK_NAME,
|
||||
"/tr", task_cmd,
|
||||
"/sc", "minute",
|
||||
"/mo", str(max(1, interval_minutes)),
|
||||
"/rl", "limited",
|
||||
"/f",
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
|
||||
def uninstall_schedule() -> bool:
|
||||
"""Delete the scheduled task. Returns True if it existed."""
|
||||
result = subprocess.run(
|
||||
["schtasks", "/delete", "/tn", TASK_NAME, "/f"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
return result.returncode == 0
|
||||
|
||||
|
||||
def schedule_status() -> dict:
|
||||
"""Query Task Scheduler for this task's status."""
|
||||
result = subprocess.run(
|
||||
["schtasks", "/query", "/tn", TASK_NAME, "/fo", "LIST"],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
installed = result.returncode == 0
|
||||
return {
|
||||
"installed": installed,
|
||||
"loaded": installed,
|
||||
"task_name": TASK_NAME,
|
||||
"details": result.stdout.strip() if installed else "",
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
"""Interactive setup wizard.
|
||||
|
||||
Runs a step-by-step flow: detect OS, locate Dropbox, collect & verify a
|
||||
token, preview tagged files, apply tags, and optionally install a
|
||||
recurring schedule. Safe to re-run — existing settings are offered as
|
||||
defaults.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import getpass
|
||||
import sys
|
||||
import textwrap
|
||||
import time
|
||||
import webbrowser
|
||||
from pathlib import Path
|
||||
|
||||
from . import config, core, detect
|
||||
|
||||
|
||||
# --- Small UI helpers ---------------------------------------------------------
|
||||
#
|
||||
# Kept inline (no extra module) so the wizard stays readable in one place.
|
||||
|
||||
_USE_COLOR = sys.stdout.isatty()
|
||||
|
||||
|
||||
def _c(s: str, code: str) -> str:
|
||||
return f"\033[{code}m{s}\033[0m" if _USE_COLOR else s
|
||||
|
||||
|
||||
def _bold(s: str) -> str: return _c(s, "1")
|
||||
def _dim(s: str) -> str: return _c(s, "2")
|
||||
def _green(s: str) -> str: return _c(s, "32")
|
||||
def _yellow(s: str) -> str: return _c(s, "33")
|
||||
def _red(s: str) -> str: return _c(s, "31")
|
||||
def _blue(s: str) -> str: return _c(s, "34")
|
||||
|
||||
|
||||
def header(title: str) -> None:
|
||||
bar = "=" * max(len(title), 40)
|
||||
print()
|
||||
print(_bold(title))
|
||||
print(_bold(bar))
|
||||
|
||||
|
||||
def step(n: str, title: str) -> None:
|
||||
print()
|
||||
print(_blue(f"[{n}] ") + _bold(title))
|
||||
|
||||
|
||||
def info(msg: str) -> None:
|
||||
print(f" {msg}")
|
||||
|
||||
|
||||
def dim(msg: str) -> None:
|
||||
print(f" {_dim(msg)}")
|
||||
|
||||
|
||||
def success(msg: str) -> None:
|
||||
print(f" {_green('OK')} {msg}")
|
||||
|
||||
|
||||
def warn(msg: str) -> None:
|
||||
print(f" {_yellow('!')} {msg}")
|
||||
|
||||
|
||||
def error(msg: str) -> None:
|
||||
print(f" {_red('x')} {msg}", file=sys.stderr)
|
||||
|
||||
|
||||
def paragraph(text: str) -> None:
|
||||
for line in textwrap.dedent(text).strip().splitlines():
|
||||
print(f" {line}")
|
||||
|
||||
|
||||
def prompt_yesno(question: str, default: bool = True) -> bool:
|
||||
suffix = "[Y/n]" if default else "[y/N]"
|
||||
while True:
|
||||
raw = input(f" {question} {suffix}: ").strip().lower()
|
||||
if raw == "":
|
||||
return default
|
||||
if raw in ("y", "yes"):
|
||||
return True
|
||||
if raw in ("n", "no"):
|
||||
return False
|
||||
|
||||
|
||||
def prompt_text(question: str, default: str = "") -> str:
|
||||
suffix = f" [{default}]" if default else ""
|
||||
raw = input(f" {question}{suffix}: ").strip()
|
||||
return raw or default
|
||||
|
||||
|
||||
def prompt_int(question: str, default: int) -> int:
|
||||
while True:
|
||||
raw = prompt_text(question, str(default))
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError:
|
||||
error("Please enter a number.")
|
||||
|
||||
|
||||
# --- Wizard steps -------------------------------------------------------------
|
||||
|
||||
|
||||
def run_wizard(entry_script: Path) -> int:
|
||||
"""Entry point for `tagsync.py wizard`. Returns process exit code."""
|
||||
header("Dropbox Tag Sync — Setup Wizard")
|
||||
|
||||
plat = detect.detect_platform()
|
||||
info(f"System: {detect.platform_label()}")
|
||||
info(f"Runtime: {detect.python_label()}")
|
||||
|
||||
if plat == "linux":
|
||||
print()
|
||||
error("This tool currently supports macOS and Windows only.")
|
||||
return 1
|
||||
|
||||
existing = config.Settings.load()
|
||||
|
||||
local_root = _step_dropbox_folder(existing)
|
||||
if local_root is None:
|
||||
return 1
|
||||
|
||||
if not _step_check_deps(plat):
|
||||
return 1
|
||||
|
||||
exiftool_path = ""
|
||||
if plat == "windows":
|
||||
tool = detect.find_exiftool()
|
||||
if tool is None:
|
||||
error("ExifTool is required on Windows to write file tags.")
|
||||
info("Download it here: https://exiftool.org/")
|
||||
if prompt_yesno("Open the ExifTool download page now?", default=True):
|
||||
webbrowser.open("https://exiftool.org/")
|
||||
info("After installing, re-run the wizard.")
|
||||
return 1
|
||||
exiftool_path = str(tool)
|
||||
success(f"ExifTool found: {exiftool_path}")
|
||||
|
||||
token = _step_token(existing_token=config.load_token())
|
||||
if token is None:
|
||||
return 1
|
||||
|
||||
skip_online_only = existing.skip_online_only
|
||||
write_sidecars = existing.write_sidecars
|
||||
if plat == "windows":
|
||||
skip_online_only, write_sidecars = _step_windows_options(
|
||||
skip_online_only, write_sidecars
|
||||
)
|
||||
|
||||
# Save settings so far — even if the user aborts the sync step, they can
|
||||
# come back and run `tagsync.py run` without re-entering everything.
|
||||
settings = config.Settings(
|
||||
local_root=str(local_root),
|
||||
platform=plat,
|
||||
exiftool=exiftool_path,
|
||||
skip_online_only=skip_online_only,
|
||||
write_sidecars=write_sidecars,
|
||||
schedule_installed=existing.schedule_installed,
|
||||
schedule_interval_minutes=existing.schedule_interval_minutes,
|
||||
)
|
||||
settings.save()
|
||||
|
||||
tagged = _step_preview(token, local_root)
|
||||
if tagged is None:
|
||||
return 1
|
||||
|
||||
if tagged:
|
||||
applied = _step_apply(
|
||||
token, local_root, settings, entry_script, tagged
|
||||
)
|
||||
else:
|
||||
info("There's nothing to tag right now. You can always re-run later.")
|
||||
applied = True
|
||||
|
||||
_step_schedule(entry_script, settings)
|
||||
|
||||
print()
|
||||
success("Setup complete.")
|
||||
info(
|
||||
"Re-run anytime with: "
|
||||
+ _bold(f"{sys.executable} {entry_script} run")
|
||||
)
|
||||
info(
|
||||
"Check schedule with: "
|
||||
+ _bold(f"{sys.executable} {entry_script} status")
|
||||
)
|
||||
return 0 if applied else 1
|
||||
|
||||
|
||||
# --- Step: locate Dropbox folder ---------------------------------------------
|
||||
|
||||
|
||||
def _step_dropbox_folder(existing: config.Settings) -> Path | None:
|
||||
step("1/6", "Locate your Dropbox folder")
|
||||
|
||||
candidate = None
|
||||
if existing.local_root and Path(existing.local_root).is_dir():
|
||||
candidate = Path(existing.local_root)
|
||||
info(f"Previously configured: {candidate}")
|
||||
else:
|
||||
candidate = detect.detect_dropbox_folder()
|
||||
if candidate:
|
||||
info(f"Auto-detected: {candidate}")
|
||||
else:
|
||||
warn("Could not auto-detect your Dropbox folder.")
|
||||
|
||||
if candidate and prompt_yesno("Is this the right folder?", default=True):
|
||||
return candidate
|
||||
|
||||
while True:
|
||||
typed = prompt_text("Enter the full path to your Dropbox folder")
|
||||
if not typed:
|
||||
error("Path is required.")
|
||||
continue
|
||||
path = Path(typed).expanduser()
|
||||
if not path.is_dir():
|
||||
error(f"Not a directory: {path}")
|
||||
continue
|
||||
return path
|
||||
|
||||
|
||||
# --- Step: dependency check --------------------------------------------------
|
||||
|
||||
|
||||
def _step_check_deps(plat: str) -> bool:
|
||||
step("2/6", "Check dependencies")
|
||||
|
||||
ok = True
|
||||
if detect.has_module("dropbox"):
|
||||
success("dropbox package installed")
|
||||
else:
|
||||
error("The 'dropbox' Python package is not installed.")
|
||||
info("Install it with: pip install -r requirements.txt")
|
||||
ok = False
|
||||
|
||||
if detect.has_module("keyring"):
|
||||
success("keyring installed (secure token storage)")
|
||||
else:
|
||||
warn("keyring not installed — token will fall back to a plaintext file.")
|
||||
info("For best security: pip install keyring")
|
||||
return ok
|
||||
|
||||
|
||||
# --- Step: token -------------------------------------------------------------
|
||||
|
||||
|
||||
_TOKEN_INSTRUCTIONS = """\
|
||||
A Dropbox access token is needed so this tool can read your tags.
|
||||
The token only has permission to READ METADATA — it cannot modify or
|
||||
delete anything in your Dropbox.
|
||||
|
||||
You'll create the token on the Dropbox developer page:
|
||||
1. Click "Create app"
|
||||
2. Choose: Scoped access, Full Dropbox, name it "tag-sync"
|
||||
3. Open the Permissions tab, enable files.metadata.read, click Submit
|
||||
4. Return to the Settings tab, scroll to "Generated access token",
|
||||
and click Generate
|
||||
5. Copy the token shown
|
||||
"""
|
||||
|
||||
|
||||
def _step_token(existing_token: str | None) -> str | None:
|
||||
step("3/6", "Dropbox access token")
|
||||
|
||||
token = None
|
||||
if existing_token:
|
||||
info("A saved token was found.")
|
||||
if prompt_yesno("Use the existing token?", default=True):
|
||||
token = existing_token
|
||||
|
||||
if not token:
|
||||
paragraph(_TOKEN_INSTRUCTIONS)
|
||||
if prompt_yesno("Open the Dropbox developer page in your browser?", default=True):
|
||||
webbrowser.open("https://www.dropbox.com/developers/apps")
|
||||
token = getpass.getpass(" Paste your access token (input hidden): ").strip()
|
||||
if not token:
|
||||
error("No token provided.")
|
||||
return None
|
||||
|
||||
info("Verifying token with Dropbox...")
|
||||
try:
|
||||
account = core.verify_token(token)
|
||||
except Exception as e:
|
||||
error(f"Token verification failed: {e}")
|
||||
return None
|
||||
success(f"Signed in as {account['name']} <{account['email']}>")
|
||||
|
||||
if token != existing_token:
|
||||
location = config.save_token(token)
|
||||
info(f"Token stored in {location}.")
|
||||
return token
|
||||
|
||||
|
||||
# --- Step: Windows-only behavior options -------------------------------------
|
||||
|
||||
|
||||
def _step_windows_options(skip_online_only: bool, write_sidecars: bool) -> tuple[bool, bool]:
|
||||
step("3b/6", "Windows tagging options")
|
||||
paragraph(
|
||||
"""
|
||||
Windows embeds tags inside files, so only certain formats can be
|
||||
tagged (images, PDFs, Office docs, most media). A few options:
|
||||
"""
|
||||
)
|
||||
skip_online_only = prompt_yesno(
|
||||
"Skip files that are online-only (not yet downloaded)?",
|
||||
default=skip_online_only,
|
||||
)
|
||||
write_sidecars = prompt_yesno(
|
||||
"Also write .tags.json sidecar files for unsupported types? "
|
||||
"(Not indexed by Explorer; useful for custom tooling)",
|
||||
default=write_sidecars,
|
||||
)
|
||||
return skip_online_only, write_sidecars
|
||||
|
||||
|
||||
# --- Step: preview -----------------------------------------------------------
|
||||
|
||||
|
||||
def _step_preview(token: str, local_root: Path) -> list[core.TaggedFile] | None:
|
||||
step("4/6", "Scan Dropbox for tagged files")
|
||||
info("This may take a minute for large accounts...")
|
||||
|
||||
start = time.time()
|
||||
listed = {"n": 0}
|
||||
last_tag_report = {"t": 0.0}
|
||||
|
||||
def on_list(n: int) -> None:
|
||||
listed["n"] = n
|
||||
info(f" listed {n} files...")
|
||||
|
||||
def on_tag(done: int, total: int) -> None:
|
||||
now = time.time()
|
||||
if now - last_tag_report["t"] < 1.0 and done < total:
|
||||
return
|
||||
last_tag_report["t"] = now
|
||||
pct = int(100 * done / total) if total else 100
|
||||
info(f" fetched tags for {done}/{total} files ({pct}%)")
|
||||
|
||||
try:
|
||||
tagged = core.collect_tagged_files(
|
||||
token, local_root, on_list_progress=on_list, on_tag_progress=on_tag
|
||||
)
|
||||
except Exception as e:
|
||||
error(f"Scan failed: {e}")
|
||||
return None
|
||||
|
||||
elapsed = time.time() - start
|
||||
success(
|
||||
f"Found {len(tagged)} tagged file(s) across {listed['n']} "
|
||||
f"total ({elapsed:.1f}s)."
|
||||
)
|
||||
|
||||
if tagged:
|
||||
info("")
|
||||
info("Sample:")
|
||||
for tf in tagged[:5]:
|
||||
dim(f" {tf.path_display} -> {', '.join(tf.tags)}")
|
||||
if len(tagged) > 5:
|
||||
dim(f" ... and {len(tagged) - 5} more")
|
||||
return tagged
|
||||
|
||||
|
||||
# --- Step: apply -------------------------------------------------------------
|
||||
|
||||
|
||||
def _step_apply(
|
||||
token: str,
|
||||
local_root: Path,
|
||||
settings: config.Settings,
|
||||
entry_script: Path,
|
||||
tagged: list[core.TaggedFile],
|
||||
) -> bool:
|
||||
step("5/6", "Apply tags to local files")
|
||||
|
||||
if settings.platform == "windows":
|
||||
warn(
|
||||
"On Windows, each tagged file will be modified in place and "
|
||||
"Dropbox will re-upload it once."
|
||||
)
|
||||
if not prompt_yesno(f"Tag {len(tagged)} file(s) now?", default=True):
|
||||
info("Skipped. You can run this later with:")
|
||||
info(f" {sys.executable} {entry_script} run")
|
||||
return True
|
||||
|
||||
writer = _build_writer(settings)
|
||||
stats = core.run_sync(
|
||||
token=token,
|
||||
local_root=local_root,
|
||||
state_db=config.STATE_DB_FILE,
|
||||
writer=writer,
|
||||
skip_online_only=settings.skip_online_only,
|
||||
dry_run=False,
|
||||
)
|
||||
print()
|
||||
info(f"Result: {stats.summary()}")
|
||||
if stats.errors:
|
||||
warn(f"{stats.errors} file(s) failed to tag — see above for details.")
|
||||
return False
|
||||
success("All tags applied successfully.")
|
||||
return True
|
||||
|
||||
|
||||
def _build_writer(settings: config.Settings):
|
||||
if settings.platform == "macos":
|
||||
from .platform_macos import MacWriter
|
||||
return MacWriter()
|
||||
if settings.platform == "windows":
|
||||
from .platform_windows import WindowsWriter
|
||||
return WindowsWriter(
|
||||
exiftool=settings.exiftool,
|
||||
write_sidecars=settings.write_sidecars,
|
||||
)
|
||||
raise RuntimeError(f"unsupported platform: {settings.platform}")
|
||||
|
||||
|
||||
# --- Step: schedule ----------------------------------------------------------
|
||||
|
||||
|
||||
def _step_schedule(entry_script: Path, settings: config.Settings) -> None:
|
||||
step("6/6", "Automatic sync")
|
||||
paragraph(
|
||||
"""
|
||||
New Dropbox tags only show up locally after this tool runs. You
|
||||
can schedule it to run automatically in the background.
|
||||
"""
|
||||
)
|
||||
|
||||
if not prompt_yesno("Install a scheduled task to sync periodically?", default=True):
|
||||
info("Skipped. You can still run the tool manually.")
|
||||
return
|
||||
|
||||
interval = prompt_int("Run every how many minutes?", settings.schedule_interval_minutes or 60)
|
||||
interval = max(5, interval)
|
||||
|
||||
try:
|
||||
if settings.platform == "macos":
|
||||
from . import platform_macos
|
||||
plist = platform_macos.install_schedule(entry_script, interval_minutes=interval)
|
||||
success(f"Installed launchd agent: {plist}")
|
||||
else:
|
||||
from . import platform_windows
|
||||
platform_windows.install_schedule(entry_script, interval_minutes=interval)
|
||||
success(f"Installed scheduled task: {platform_windows.TASK_NAME}")
|
||||
except Exception as e:
|
||||
error(f"Failed to install schedule: {e}")
|
||||
info("You can install it manually later with:")
|
||||
info(f" {sys.executable} {entry_script} schedule install")
|
||||
return
|
||||
|
||||
settings.schedule_installed = True
|
||||
settings.schedule_interval_minutes = interval
|
||||
settings.save()
|
||||
@@ -0,0 +1,122 @@
|
||||
"""Shared pytest fixtures and helpers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# --- Isolate persistent state paths from the real user's home ---------------
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate_user_state(tmp_path, monkeypatch):
|
||||
"""Redirect all per-user state files into a tmp dir.
|
||||
|
||||
Ensures tests never clobber real settings, tokens, or state DB on the
|
||||
developer's machine.
|
||||
"""
|
||||
fake_home = tmp_path / "home"
|
||||
fake_home.mkdir()
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: fake_home))
|
||||
|
||||
# Re-import config so its module-level paths re-evaluate against the
|
||||
# patched home. We clear and re-import to reset cached module constants.
|
||||
import importlib
|
||||
from tagsync import config
|
||||
|
||||
importlib.reload(config)
|
||||
yield
|
||||
# Leave things as-is after the test; tmp_path cleanup handles disk.
|
||||
|
||||
|
||||
# --- Fake Dropbox API objects ------------------------------------------------
|
||||
#
|
||||
# The Dropbox SDK may or may not be installed in the test env. We don't want
|
||||
# to require it, and the parts of core.py that need it are only reached via
|
||||
# helpers we can monkeypatch out.
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeFileMetadata:
|
||||
"""Stand-in for dropbox.files.FileMetadata."""
|
||||
name: str
|
||||
path_lower: str
|
||||
path_display: str
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeUserTag:
|
||||
tag_text: str
|
||||
|
||||
|
||||
class FakeTag:
|
||||
"""Stand-in for dropbox.files.Tag (union type)."""
|
||||
def __init__(self, tag_text: str) -> None:
|
||||
self._user = FakeUserTag(tag_text)
|
||||
|
||||
def get_user_generated_tag(self) -> FakeUserTag:
|
||||
return self._user
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakePathToTags:
|
||||
path: str
|
||||
tags: list
|
||||
|
||||
|
||||
@dataclass
|
||||
class FakeTagsResult:
|
||||
paths_to_tags: list
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def patched_core(monkeypatch):
|
||||
"""Monkeypatch core's Dropbox-dependent helpers to controllable fakes.
|
||||
|
||||
Tests receive a `state` dict where they can configure:
|
||||
- `files`: list of FakeFileMetadata returned by _list_all_files
|
||||
- `tags_by_path`: dict[path_lower -> list[str]] returned by _fetch_tags
|
||||
"""
|
||||
from tagsync import core
|
||||
|
||||
state = {
|
||||
"files": [],
|
||||
"tags_by_path": {},
|
||||
"fetch_calls": 0,
|
||||
}
|
||||
|
||||
def fake_client(_token):
|
||||
return object() # placeholder; never used because list/fetch are faked
|
||||
|
||||
def fake_list(_dbx):
|
||||
return list(state["files"])
|
||||
|
||||
def fake_fetch(_dbx, files):
|
||||
state["fetch_calls"] += 1
|
||||
for f in files:
|
||||
tag_names = sorted(state["tags_by_path"].get(f.path_lower, []))
|
||||
yield f, tag_names
|
||||
|
||||
monkeypatch.setattr(core, "_dropbox_client", fake_client)
|
||||
monkeypatch.setattr(core, "_list_all_files", fake_list)
|
||||
monkeypatch.setattr(core, "_fetch_tags", fake_fetch)
|
||||
return state
|
||||
|
||||
|
||||
# --- Marker-based skips ------------------------------------------------------
|
||||
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
platform_skip = {
|
||||
"macos": sys.platform != "darwin",
|
||||
"windows": sys.platform != "win32",
|
||||
}
|
||||
for item in items:
|
||||
for mark_name, should_skip in platform_skip.items():
|
||||
if mark_name in item.keywords and should_skip:
|
||||
item.add_marker(
|
||||
pytest.mark.skip(reason=f"requires {mark_name}")
|
||||
)
|
||||
@@ -0,0 +1,114 @@
|
||||
"""Tests for tagsync.config: Settings persistence + token storage."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def config_module():
|
||||
from tagsync import config
|
||||
return importlib.reload(config)
|
||||
|
||||
|
||||
# --- Settings ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_settings_defaults(config_module):
|
||||
s = config_module.Settings()
|
||||
assert s.local_root == ""
|
||||
assert s.platform == ""
|
||||
assert s.skip_online_only is True
|
||||
assert s.write_sidecars is False
|
||||
assert s.schedule_installed is False
|
||||
assert s.schedule_interval_minutes == 60
|
||||
|
||||
|
||||
def test_settings_roundtrip(config_module, tmp_path):
|
||||
path = tmp_path / "cfg.json"
|
||||
original = config_module.Settings(
|
||||
local_root="/home/u/Dropbox",
|
||||
platform="macos",
|
||||
skip_online_only=False,
|
||||
schedule_installed=True,
|
||||
schedule_interval_minutes=30,
|
||||
)
|
||||
original.save(path)
|
||||
|
||||
loaded = config_module.Settings.load(path)
|
||||
assert loaded == original
|
||||
|
||||
|
||||
def test_settings_load_missing_file_returns_defaults(config_module, tmp_path):
|
||||
loaded = config_module.Settings.load(tmp_path / "nope.json")
|
||||
assert loaded == config_module.Settings()
|
||||
|
||||
|
||||
def test_settings_load_ignores_unknown_keys(config_module, tmp_path):
|
||||
"""Older configs should load even if a new field was removed."""
|
||||
path = tmp_path / "cfg.json"
|
||||
path.write_text(json.dumps({
|
||||
"local_root": "/x",
|
||||
"platform": "macos",
|
||||
"future_field_we_dont_know_about": 42,
|
||||
}))
|
||||
loaded = config_module.Settings.load(path)
|
||||
assert loaded.local_root == "/x"
|
||||
assert loaded.platform == "macos"
|
||||
|
||||
|
||||
# --- Token storage (fallback file path) -------------------------------------
|
||||
|
||||
|
||||
def test_token_fallback_roundtrip(config_module, monkeypatch):
|
||||
"""Force the non-keyring fallback path and verify round-trip."""
|
||||
monkeypatch.setattr(config_module, "_keyring_available", lambda: False)
|
||||
|
||||
assert config_module.load_token() is None
|
||||
|
||||
location = config_module.save_token("sl.test-token-123")
|
||||
assert "plaintext" in location
|
||||
|
||||
assert config_module.load_token() == "sl.test-token-123"
|
||||
|
||||
config_module.clear_token()
|
||||
assert config_module.load_token() is None
|
||||
|
||||
|
||||
def test_token_fallback_file_permissions(config_module, monkeypatch):
|
||||
"""The fallback token file should be mode 0600 where supported."""
|
||||
import os
|
||||
import stat
|
||||
|
||||
monkeypatch.setattr(config_module, "_keyring_available", lambda: False)
|
||||
config_module.save_token("sl.perm-test")
|
||||
|
||||
mode = os.stat(config_module.TOKEN_FALLBACK_FILE).st_mode
|
||||
# Skip on Windows where POSIX modes aren't enforced the same way.
|
||||
if os.name == "posix":
|
||||
assert stat.S_IMODE(mode) == 0o600
|
||||
|
||||
|
||||
@pytest.mark.needs_keyring
|
||||
def test_token_keyring_roundtrip(config_module):
|
||||
"""If keyring is installed, verify we actually use it."""
|
||||
pytest.importorskip("keyring")
|
||||
|
||||
if not config_module._keyring_available():
|
||||
pytest.skip("keyring not importable")
|
||||
|
||||
import keyring
|
||||
# Use the in-memory fallback backend to avoid touching real Keychain.
|
||||
from keyring.backends import fail as _fail # noqa: F401 (ensure package)
|
||||
|
||||
try:
|
||||
import keyring.backends.null
|
||||
keyring.set_keyring(keyring.backends.null.Keyring())
|
||||
except Exception:
|
||||
pytest.skip("no safe keyring backend available for tests")
|
||||
|
||||
# Null keyring swallows set/get — just exercise code paths.
|
||||
config_module.save_token("sl.kr-test")
|
||||
config_module.clear_token()
|
||||
@@ -0,0 +1,268 @@
|
||||
"""Tests for tagsync.core: state DB + run_sync main loop.
|
||||
|
||||
Dropbox client calls are monkeypatched via the `patched_core` fixture, so
|
||||
these tests don't require network or the dropbox SDK to be installed.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagsync import core
|
||||
from tests.conftest import FakeFileMetadata
|
||||
|
||||
|
||||
# --- State DB ---------------------------------------------------------------
|
||||
|
||||
|
||||
def test_open_state_creates_schema(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
conn = core.open_state(db)
|
||||
try:
|
||||
tables = [
|
||||
row[0]
|
||||
for row in conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table'"
|
||||
)
|
||||
]
|
||||
finally:
|
||||
conn.close()
|
||||
assert "file_tags" in tables
|
||||
|
||||
|
||||
def test_open_state_is_idempotent(tmp_path):
|
||||
db = tmp_path / "state.db"
|
||||
core.open_state(db).close()
|
||||
# Re-opening an existing DB should not raise.
|
||||
core.open_state(db).close()
|
||||
|
||||
|
||||
def test_open_state_creates_parent_dir(tmp_path):
|
||||
db = tmp_path / "nested" / "dir" / "state.db"
|
||||
conn = core.open_state(db)
|
||||
conn.close()
|
||||
assert db.exists()
|
||||
|
||||
|
||||
# --- SyncStats --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_sync_stats_summary_format():
|
||||
s = core.SyncStats(applied=3, cleared=1, errors=0)
|
||||
summary = s.summary()
|
||||
for key in ("applied", "cleared", "skipped", "missing", "errors"):
|
||||
assert key in summary
|
||||
|
||||
|
||||
# --- Writer implementations used by run_sync --------------------------------
|
||||
|
||||
|
||||
class RecordingWriter:
|
||||
"""Fake Writer that records every call for assertions."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
supports_all: bool = True,
|
||||
online_paths: set[str] | None = None,
|
||||
fail_paths: set[str] | None = None,
|
||||
):
|
||||
self.supports_all = supports_all
|
||||
self.online_paths = online_paths or set()
|
||||
self.fail_paths = fail_paths or set()
|
||||
self.written: list[tuple[Path, list[str]]] = []
|
||||
|
||||
def supports(self, path: Path) -> bool:
|
||||
return self.supports_all
|
||||
|
||||
def is_online_only(self, path: Path) -> bool:
|
||||
return str(path) in self.online_paths
|
||||
|
||||
def write_tags(self, path: Path, tags: list[str]) -> None:
|
||||
if str(path) in self.fail_paths:
|
||||
raise RuntimeError("simulated tag failure")
|
||||
self.written.append((path, list(tags)))
|
||||
|
||||
|
||||
# --- run_sync happy paths ---------------------------------------------------
|
||||
|
||||
|
||||
def _make_local_file(local_root: Path, rel: str) -> Path:
|
||||
p = local_root / rel
|
||||
p.parent.mkdir(parents=True, exist_ok=True)
|
||||
p.touch()
|
||||
return p
|
||||
|
||||
|
||||
def test_run_sync_applies_new_tags(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "Report.pdf")
|
||||
|
||||
patched_core["files"] = [
|
||||
FakeFileMetadata(name="Report.pdf", path_lower="/report.pdf",
|
||||
path_display="/Report.pdf"),
|
||||
]
|
||||
patched_core["tags_by_path"] = {"/report.pdf": ["urgent", "client"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
stats = core.run_sync(
|
||||
token="fake",
|
||||
local_root=local_root,
|
||||
state_db=tmp_path / "state.db",
|
||||
writer=writer,
|
||||
)
|
||||
assert stats.applied == 1
|
||||
assert stats.errors == 0
|
||||
assert writer.written == [
|
||||
(local_root / "Report.pdf", ["client", "urgent"]), # sorted
|
||||
]
|
||||
|
||||
|
||||
def test_run_sync_skips_unchanged_tags(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "a.pdf")
|
||||
|
||||
patched_core["files"] = [
|
||||
FakeFileMetadata("a.pdf", "/a.pdf", "/a.pdf")
|
||||
]
|
||||
patched_core["tags_by_path"] = {"/a.pdf": ["x"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
state_db = tmp_path / "state.db"
|
||||
|
||||
first = core.run_sync("t", local_root, state_db, writer)
|
||||
assert first.applied == 1
|
||||
|
||||
# Second run with the same Dropbox state should skip.
|
||||
second = core.run_sync("t", local_root, state_db, writer)
|
||||
assert second.applied == 0
|
||||
assert second.skipped == 1
|
||||
assert len(writer.written) == 1 # unchanged from first run
|
||||
|
||||
|
||||
def test_run_sync_clears_tags_when_removed_upstream(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "b.pdf")
|
||||
|
||||
patched_core["files"] = [FakeFileMetadata("b.pdf", "/b.pdf", "/b.pdf")]
|
||||
patched_core["tags_by_path"] = {"/b.pdf": ["temp"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
state_db = tmp_path / "state.db"
|
||||
core.run_sync("t", local_root, state_db, writer)
|
||||
|
||||
# Upstream removes the tag.
|
||||
patched_core["tags_by_path"] = {"/b.pdf": []}
|
||||
stats = core.run_sync("t", local_root, state_db, writer)
|
||||
assert stats.cleared == 1
|
||||
assert writer.written[-1] == (local_root / "b.pdf", [])
|
||||
|
||||
|
||||
def test_run_sync_skips_missing_local_files(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
# Don't create the local file.
|
||||
|
||||
patched_core["files"] = [FakeFileMetadata("c.pdf", "/c.pdf", "/c.pdf")]
|
||||
patched_core["tags_by_path"] = {"/c.pdf": ["x"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
stats = core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer
|
||||
)
|
||||
assert stats.missing == 1
|
||||
assert writer.written == []
|
||||
|
||||
|
||||
def test_run_sync_respects_unsupported_files(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "data.zip")
|
||||
|
||||
patched_core["files"] = [
|
||||
FakeFileMetadata("data.zip", "/data.zip", "/data.zip")
|
||||
]
|
||||
patched_core["tags_by_path"] = {"/data.zip": ["archive"]}
|
||||
|
||||
writer = RecordingWriter(supports_all=False)
|
||||
stats = core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer
|
||||
)
|
||||
assert stats.unsupported == 1
|
||||
assert stats.applied == 0
|
||||
|
||||
|
||||
def test_run_sync_skips_online_only_files_when_requested(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
local_file = _make_local_file(local_root, "cloud.pdf")
|
||||
|
||||
patched_core["files"] = [FakeFileMetadata("cloud.pdf", "/cloud.pdf", "/cloud.pdf")]
|
||||
patched_core["tags_by_path"] = {"/cloud.pdf": ["tag"]}
|
||||
|
||||
writer = RecordingWriter(online_paths={str(local_file)})
|
||||
stats = core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer,
|
||||
skip_online_only=True,
|
||||
)
|
||||
assert stats.online_only == 1
|
||||
assert stats.applied == 0
|
||||
|
||||
|
||||
def test_run_sync_dry_run_touches_nothing(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "d.pdf")
|
||||
|
||||
patched_core["files"] = [FakeFileMetadata("d.pdf", "/d.pdf", "/d.pdf")]
|
||||
patched_core["tags_by_path"] = {"/d.pdf": ["x"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
stats = core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer, dry_run=True,
|
||||
)
|
||||
assert stats.applied == 1
|
||||
assert writer.written == [] # nothing actually written
|
||||
|
||||
|
||||
def test_run_sync_counts_writer_errors(tmp_path, patched_core):
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
local_file = _make_local_file(local_root, "e.pdf")
|
||||
|
||||
patched_core["files"] = [FakeFileMetadata("e.pdf", "/e.pdf", "/e.pdf")]
|
||||
patched_core["tags_by_path"] = {"/e.pdf": ["x"]}
|
||||
|
||||
writer = RecordingWriter(fail_paths={str(local_file)})
|
||||
stats = core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer,
|
||||
)
|
||||
assert stats.errors == 1
|
||||
assert stats.applied == 0
|
||||
|
||||
|
||||
# --- Path translation -------------------------------------------------------
|
||||
|
||||
|
||||
def test_path_display_to_local(tmp_path, patched_core):
|
||||
"""Dropbox's /A/B/C.pdf must map under the configured local root."""
|
||||
local_root = tmp_path / "Dropbox"
|
||||
local_root.mkdir()
|
||||
_make_local_file(local_root, "A/B/C.pdf")
|
||||
|
||||
patched_core["files"] = [
|
||||
FakeFileMetadata("C.pdf", "/a/b/c.pdf", "/A/B/C.pdf")
|
||||
]
|
||||
patched_core["tags_by_path"] = {"/a/b/c.pdf": ["t"]}
|
||||
|
||||
writer = RecordingWriter()
|
||||
core.run_sync(
|
||||
"t", local_root, tmp_path / "state.db", writer,
|
||||
)
|
||||
assert writer.written
|
||||
written_path, _ = writer.written[0]
|
||||
assert written_path == local_root / "A" / "B" / "C.pdf"
|
||||
@@ -0,0 +1,109 @@
|
||||
"""Tests for tagsync.detect: platform detection + Dropbox folder probing."""
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def detect_module():
|
||||
from tagsync import detect
|
||||
return importlib.reload(detect)
|
||||
|
||||
|
||||
def test_detect_platform_returns_known_value(detect_module):
|
||||
assert detect_module.detect_platform() in {"macos", "windows", "linux"}
|
||||
|
||||
|
||||
def test_platform_label_nonempty(detect_module):
|
||||
assert detect_module.platform_label()
|
||||
|
||||
|
||||
def test_python_label_nonempty(detect_module):
|
||||
label = detect_module.python_label()
|
||||
assert label.startswith("Python ")
|
||||
|
||||
|
||||
def test_has_module_true_for_stdlib(detect_module):
|
||||
assert detect_module.has_module("json")
|
||||
assert detect_module.has_module("pathlib")
|
||||
|
||||
|
||||
def test_has_module_false_for_garbage(detect_module):
|
||||
assert not detect_module.has_module("definitely_not_a_real_module_xyz")
|
||||
|
||||
|
||||
def test_detect_dropbox_folder_from_info_json(detect_module, tmp_path, monkeypatch):
|
||||
"""When Dropbox's info.json exists, we should parse its path field."""
|
||||
dropbox_dir = tmp_path / "Dropbox"
|
||||
dropbox_dir.mkdir()
|
||||
info_dir = tmp_path / ".dropbox"
|
||||
info_dir.mkdir()
|
||||
info = info_dir / "info.json"
|
||||
info.write_text(json.dumps({
|
||||
"personal": {"path": str(dropbox_dir), "host": 12345},
|
||||
}))
|
||||
|
||||
monkeypatch.setattr(
|
||||
detect_module,
|
||||
"_dropbox_info_candidates",
|
||||
lambda: [info],
|
||||
)
|
||||
result = detect_module.detect_dropbox_folder()
|
||||
assert result == dropbox_dir
|
||||
|
||||
|
||||
def test_detect_dropbox_folder_handles_malformed_json(detect_module, tmp_path, monkeypatch):
|
||||
info = tmp_path / "info.json"
|
||||
info.write_text("not valid json {{{")
|
||||
|
||||
monkeypatch.setattr(
|
||||
detect_module,
|
||||
"_dropbox_info_candidates",
|
||||
lambda: [info],
|
||||
)
|
||||
# Should fall back silently, not raise.
|
||||
result = detect_module.detect_dropbox_folder()
|
||||
# No fallback path in tmp, so None is acceptable.
|
||||
assert result is None or result.is_dir()
|
||||
|
||||
|
||||
def test_detect_dropbox_folder_returns_none_if_missing(detect_module, monkeypatch):
|
||||
from pathlib import Path
|
||||
monkeypatch.setattr(detect_module, "_dropbox_info_candidates", lambda: [])
|
||||
monkeypatch.setattr(Path, "home", staticmethod(lambda: Path("/definitely/does/not/exist")))
|
||||
assert detect_module.detect_dropbox_folder() is None
|
||||
|
||||
|
||||
def test_detect_dropbox_folder_prefers_personal_over_business(detect_module, tmp_path, monkeypatch):
|
||||
personal = tmp_path / "Dropbox-Personal"
|
||||
business = tmp_path / "Dropbox-Business"
|
||||
personal.mkdir()
|
||||
business.mkdir()
|
||||
info = tmp_path / "info.json"
|
||||
info.write_text(json.dumps({
|
||||
"personal": {"path": str(personal)},
|
||||
"business": {"path": str(business)},
|
||||
}))
|
||||
monkeypatch.setattr(
|
||||
detect_module,
|
||||
"_dropbox_info_candidates",
|
||||
lambda: [info],
|
||||
)
|
||||
assert detect_module.detect_dropbox_folder() == personal
|
||||
|
||||
|
||||
def test_find_exiftool_uses_shutil_which(detect_module, monkeypatch):
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which", lambda name: "/usr/bin/exiftool" if name == "exiftool" else None)
|
||||
result = detect_module.find_exiftool()
|
||||
assert result is not None
|
||||
assert result.name == "exiftool"
|
||||
|
||||
|
||||
def test_find_exiftool_returns_none_when_absent(detect_module, monkeypatch):
|
||||
import shutil
|
||||
monkeypatch.setattr(shutil, "which", lambda _name: None)
|
||||
assert detect_module.find_exiftool() is None
|
||||
@@ -0,0 +1,140 @@
|
||||
"""Tests for tagsync.platform_macos.
|
||||
|
||||
The plist encoding test runs anywhere. The xattr round-trip runs only on
|
||||
systems where the extended-attribute syscalls are available (macOS and
|
||||
most Linux filesystems).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import plistlib
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagsync.platform_macos import FINDER_TAGS_XATTR, MacWriter
|
||||
|
||||
|
||||
def test_mac_writer_supports_all_file_types():
|
||||
w = MacWriter()
|
||||
for path in ("a.jpg", "b.zip", "c.txt", "noextension"):
|
||||
assert w.supports(Path(path))
|
||||
|
||||
|
||||
def test_mac_writer_is_online_only_is_false():
|
||||
w = MacWriter()
|
||||
assert w.is_online_only(Path("/any/file")) is False
|
||||
|
||||
|
||||
def test_plist_encoding_matches_finder_format():
|
||||
"""Finder expects a binary plist of a string array."""
|
||||
from tagsync.platform_macos import plistlib # re-export
|
||||
tags = ["alpha", "beta", "gamma"]
|
||||
encoded = plistlib.dumps(tags, fmt=plistlib.FMT_BINARY)
|
||||
# Binary plists start with 'bplist'.
|
||||
assert encoded.startswith(b"bplist")
|
||||
decoded = plistlib.loads(encoded)
|
||||
assert decoded == tags
|
||||
|
||||
|
||||
def _xattr_supported(path: Path) -> bool:
|
||||
"""Can we setxattr / getxattr on files in this directory?"""
|
||||
if not hasattr(os, "setxattr") or not hasattr(os, "getxattr"):
|
||||
return False
|
||||
try:
|
||||
# Use a neutral namespace; on Linux, user.* always works on ext4.
|
||||
os.setxattr(path, "user.tagsync_probe", b"ok")
|
||||
val = os.getxattr(path, "user.tagsync_probe")
|
||||
os.removexattr(path, "user.tagsync_probe")
|
||||
return val == b"ok"
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def xattr_capable_file(tmp_path):
|
||||
f = tmp_path / "target"
|
||||
f.touch()
|
||||
if not _xattr_supported(f):
|
||||
pytest.skip("filesystem does not support extended attributes")
|
||||
return f
|
||||
|
||||
|
||||
def test_write_and_read_tags_via_xattr(xattr_capable_file):
|
||||
"""Round-trip the actual xattr the script uses in production.
|
||||
|
||||
On Linux, the 'com.apple.metadata:*' namespace may not be allowed,
|
||||
so this test uses the MacWriter only on Darwin.
|
||||
"""
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("Finder xattr namespace only writable on macOS")
|
||||
|
||||
w = MacWriter()
|
||||
w.write_tags(xattr_capable_file, ["alpha", "beta"])
|
||||
|
||||
raw = os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
||||
assert plistlib.loads(raw) == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_clear_tags_via_xattr(xattr_capable_file):
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("Finder xattr namespace only writable on macOS")
|
||||
|
||||
w = MacWriter()
|
||||
w.write_tags(xattr_capable_file, ["alpha"])
|
||||
w.write_tags(xattr_capable_file, [])
|
||||
|
||||
with pytest.raises(OSError):
|
||||
os.getxattr(xattr_capable_file, FINDER_TAGS_XATTR)
|
||||
|
||||
|
||||
def test_clear_tags_when_none_set_is_noop(tmp_path):
|
||||
"""Removing a non-existent xattr should not raise."""
|
||||
if sys.platform != "darwin":
|
||||
pytest.skip("requires macOS xattr semantics")
|
||||
f = tmp_path / "fresh"
|
||||
f.touch()
|
||||
MacWriter().write_tags(f, []) # should not raise
|
||||
|
||||
|
||||
# --- launchd plist generation -----------------------------------------------
|
||||
|
||||
|
||||
def test_install_schedule_writes_plist(tmp_path, monkeypatch):
|
||||
"""We don't want to actually call launchctl; intercept it and verify
|
||||
the plist contents."""
|
||||
import tagsync.platform_macos as pm
|
||||
|
||||
calls = []
|
||||
|
||||
class FakeResult:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
stdout = ""
|
||||
|
||||
monkeypatch.setattr(pm.subprocess, "run", lambda args, **kw: calls.append(args) or FakeResult())
|
||||
|
||||
# Redirect plist + log paths into tmp to avoid touching real HOME.
|
||||
fake_plist = tmp_path / "agent.plist"
|
||||
fake_log = tmp_path / "log"
|
||||
monkeypatch.setattr(pm, "LAUNCHD_PLIST", fake_plist)
|
||||
monkeypatch.setattr(pm, "LOG_PATH", fake_log)
|
||||
|
||||
entry = tmp_path / "tagsync.py"
|
||||
entry.write_text("# placeholder")
|
||||
|
||||
result_path = pm.install_schedule(entry, interval_minutes=30)
|
||||
assert result_path == fake_plist
|
||||
content = fake_plist.read_text()
|
||||
assert "StartInterval" in content
|
||||
assert "1800" in content # 30 min -> 1800 sec
|
||||
assert str(entry) in content
|
||||
assert "launchctl" in " ".join(" ".join(c) for c in calls)
|
||||
|
||||
|
||||
def test_uninstall_schedule_when_not_installed(tmp_path, monkeypatch):
|
||||
import tagsync.platform_macos as pm
|
||||
|
||||
monkeypatch.setattr(pm, "LAUNCHD_PLIST", tmp_path / "missing.plist")
|
||||
assert pm.uninstall_schedule() is False
|
||||
@@ -0,0 +1,154 @@
|
||||
"""Tests for tagsync.platform_windows: sidecar writer + support matrix.
|
||||
|
||||
Runs on any OS — the Windows-specific online-only stat test is skipped
|
||||
except on Windows.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from tagsync.platform_windows import (
|
||||
XMP_WRITABLE_EXTS,
|
||||
WindowsWriter,
|
||||
)
|
||||
|
||||
|
||||
# --- supports() --------------------------------------------------------------
|
||||
|
||||
|
||||
def test_supports_xmp_formats():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
for ext in (".jpg", ".pdf", ".docx", ".mp4"):
|
||||
assert w.supports(Path(f"foo{ext}")), f"expected support for {ext}"
|
||||
|
||||
|
||||
def test_supports_rejects_unknown_without_sidecars():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
assert not w.supports(Path("archive.zip"))
|
||||
assert not w.supports(Path("notes.txt"))
|
||||
|
||||
|
||||
def test_supports_accepts_unknown_with_sidecars():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
assert w.supports(Path("archive.zip"))
|
||||
assert w.supports(Path("notes.txt"))
|
||||
|
||||
|
||||
def test_supports_is_case_insensitive():
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
assert w.supports(Path("IMAGE.JPG"))
|
||||
assert w.supports(Path("report.PDF"))
|
||||
|
||||
|
||||
# --- sidecar writer ---------------------------------------------------------
|
||||
|
||||
|
||||
def test_sidecar_written_for_unsupported_type(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
|
||||
w.write_tags(target, ["project-alpha", "urgent"])
|
||||
|
||||
sidecar = tmp_path / "archive.zip.tags.json"
|
||||
assert sidecar.exists()
|
||||
data = json.loads(sidecar.read_text())
|
||||
assert data == {"tags": ["project-alpha", "urgent"]}
|
||||
|
||||
|
||||
def test_sidecar_cleared_when_tags_empty(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
(tmp_path / "archive.zip.tags.json").write_text('{"tags": ["x"]}')
|
||||
|
||||
w.write_tags(target, [])
|
||||
assert not (tmp_path / "archive.zip.tags.json").exists()
|
||||
|
||||
|
||||
def test_sidecar_clear_nonexistent_is_ok(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=True)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
# Should not raise even though no sidecar exists.
|
||||
w.write_tags(target, [])
|
||||
|
||||
|
||||
def test_unsupported_without_sidecars_raises(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "archive.zip"
|
||||
target.touch()
|
||||
with pytest.raises(RuntimeError):
|
||||
w.write_tags(target, ["tag"])
|
||||
|
||||
|
||||
# --- exiftool invocation ----------------------------------------------------
|
||||
#
|
||||
# We don't actually run exiftool; monkeypatch subprocess.run to capture args.
|
||||
|
||||
|
||||
def test_exiftool_args_are_well_formed(tmp_path, monkeypatch):
|
||||
captured = {}
|
||||
|
||||
def fake_run(args, **kwargs):
|
||||
captured["args"] = args
|
||||
class R:
|
||||
returncode = 0
|
||||
stderr = ""
|
||||
return R()
|
||||
|
||||
import tagsync.platform_windows as pw
|
||||
monkeypatch.setattr(pw.subprocess, "run", fake_run)
|
||||
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "pic.jpg"
|
||||
target.touch()
|
||||
w.write_tags(target, ["alpha", "beta"])
|
||||
|
||||
args = captured["args"]
|
||||
assert args[0] == "exiftool"
|
||||
assert "-overwrite_original" in args
|
||||
# Subject should be cleared then appended for each tag.
|
||||
assert "-XMP-dc:Subject=" in args
|
||||
assert "-XMP-dc:Subject+=alpha" in args
|
||||
assert "-XMP-dc:Subject+=beta" in args
|
||||
assert str(target) in args
|
||||
|
||||
|
||||
def test_exiftool_failure_raises(tmp_path, monkeypatch):
|
||||
def fake_run(args, **kwargs):
|
||||
class R:
|
||||
returncode = 1
|
||||
stderr = "bad things happened"
|
||||
return R()
|
||||
|
||||
import tagsync.platform_windows as pw
|
||||
monkeypatch.setattr(pw.subprocess, "run", fake_run)
|
||||
|
||||
w = WindowsWriter(exiftool="exiftool", write_sidecars=False)
|
||||
target = tmp_path / "pic.jpg"
|
||||
target.touch()
|
||||
with pytest.raises(RuntimeError, match="bad things happened"):
|
||||
w.write_tags(target, ["x"])
|
||||
|
||||
|
||||
# --- online-only detection --------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.windows
|
||||
def test_is_online_only_returns_false_for_normal_files(tmp_path):
|
||||
w = WindowsWriter(exiftool="exiftool")
|
||||
f = tmp_path / "regular.txt"
|
||||
f.touch()
|
||||
assert w.is_online_only(f) is False
|
||||
|
||||
|
||||
def test_is_online_only_returns_false_when_attribute_unavailable(tmp_path):
|
||||
"""On non-Windows, st_file_attributes doesn't exist -> should return False."""
|
||||
w = WindowsWriter(exiftool="exiftool")
|
||||
f = tmp_path / "regular.txt"
|
||||
f.touch()
|
||||
assert w.is_online_only(f) is False
|
||||
Reference in New Issue
Block a user