foldrive 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- foldrive/__init__.py +1 -0
- foldrive/auth.py +59 -0
- foldrive/autostart.py +178 -0
- foldrive/cli.py +199 -0
- foldrive/commands/__init__.py +1 -0
- foldrive/commands/autostart.py +16 -0
- foldrive/commands/init.py +57 -0
- foldrive/commands/log.py +67 -0
- foldrive/commands/login.py +10 -0
- foldrive/commands/logout.py +8 -0
- foldrive/commands/ls.py +42 -0
- foldrive/commands/pull.py +104 -0
- foldrive/commands/push.py +106 -0
- foldrive/commands/restore.py +90 -0
- foldrive/commands/setup.py +86 -0
- foldrive/commands/status.py +93 -0
- foldrive/commands/sync.py +9 -0
- foldrive/commands/tick.py +91 -0
- foldrive/commands/whoami.py +7 -0
- foldrive/config.py +209 -0
- foldrive/drive.py +166 -0
- foldrive/engine.py +346 -0
- foldrive/executor.py +400 -0
- foldrive/history.py +78 -0
- foldrive/logs.py +17 -0
- foldrive/paths.py +27 -0
- foldrive/progress.py +103 -0
- foldrive/prompts.py +67 -0
- foldrive/scanner.py +113 -0
- foldrive/scheduler.py +26 -0
- foldrive/state.py +47 -0
- foldrive-0.1.0.dist-info/METADATA +400 -0
- foldrive-0.1.0.dist-info/RECORD +37 -0
- foldrive-0.1.0.dist-info/WHEEL +5 -0
- foldrive-0.1.0.dist-info/entry_points.txt +2 -0
- foldrive-0.1.0.dist-info/licenses/LICENSE +21 -0
- foldrive-0.1.0.dist-info/top_level.txt +1 -0
foldrive/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__="0.1.0"
|
foldrive/auth.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
from google.auth.exceptions import RefreshError
|
|
2
|
+
from google.auth.transport.requests import Request
|
|
3
|
+
from google.oauth2.credentials import Credentials
|
|
4
|
+
from google_auth_oauthlib.flow import InstalledAppFlow
|
|
5
|
+
|
|
6
|
+
from .paths import APP_DIR, TOKEN_PATH
|
|
7
|
+
|
|
8
|
+
SCOPES = ["https://www.googleapis.com/auth/drive"]
|
|
9
|
+
CLIENT_SECRET_PATH = APP_DIR / "client_secret.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _save(creds):
|
|
13
|
+
APP_DIR.mkdir(parents=True, exist_ok=True)
|
|
14
|
+
TOKEN_PATH.write_text(creds.to_json(), encoding="utf-8")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def get_credentials():
|
|
19
|
+
if not TOKEN_PATH.exists():
|
|
20
|
+
return None
|
|
21
|
+
creds = Credentials.from_authorized_user_file(str(TOKEN_PATH),SCOPES)
|
|
22
|
+
if creds.valid:
|
|
23
|
+
return creds
|
|
24
|
+
if creds.expired and creds.refresh_token:
|
|
25
|
+
try:
|
|
26
|
+
creds.refresh(Request())
|
|
27
|
+
except RefreshError:
|
|
28
|
+
TOKEN_PATH.unlink()
|
|
29
|
+
return None
|
|
30
|
+
_save(creds)
|
|
31
|
+
return creds
|
|
32
|
+
return None
|
|
33
|
+
|
|
34
|
+
def login():
|
|
35
|
+
creds=get_credentials()
|
|
36
|
+
if creds:
|
|
37
|
+
return creds
|
|
38
|
+
flow = InstalledAppFlow.from_client_secrets_file(str(CLIENT_SECRET_PATH), SCOPES)
|
|
39
|
+
creds = flow.run_local_server(port=0)
|
|
40
|
+
_save(creds)
|
|
41
|
+
return creds
|
|
42
|
+
|
|
43
|
+
def logout():
|
|
44
|
+
if TOKEN_PATH.exists():
|
|
45
|
+
TOKEN_PATH.unlink()
|
|
46
|
+
return True
|
|
47
|
+
return False
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_service():
|
|
51
|
+
"""Build a Drive client, or exit with a hint if not logged in."""
|
|
52
|
+
from googleapiclient.discovery import build
|
|
53
|
+
|
|
54
|
+
credentials = get_credentials()
|
|
55
|
+
if credentials is None:
|
|
56
|
+
raise SystemExit("Not logged in. Run: foldrive login")
|
|
57
|
+
return build("drive", "v3", credentials=credentials)
|
|
58
|
+
|
|
59
|
+
|
foldrive/autostart.py
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
"""Registers `foldrive tick` to run in the background.
|
|
2
|
+
|
|
3
|
+
Windows uses Task Scheduler; everything else uses cron. The public install/
|
|
4
|
+
uninstall/status functions dispatch on platform, so each backend below can assume
|
|
5
|
+
it is only ever called on its own OS.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import shutil
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
TASK_NAME = "foldrive-tick"
|
|
14
|
+
INTERVAL_MINUTES = 5
|
|
15
|
+
|
|
16
|
+
# schtasks' defaults are wrong for a laptop sync tool: it refuses to start on
|
|
17
|
+
# battery and kills a running task the moment you unplug. Those flags can't be set
|
|
18
|
+
# from the schtasks command line, and registering from XML needs elevation, so the
|
|
19
|
+
# task is created the plain way and then patched through the PowerShell API.
|
|
20
|
+
POWER_SETTINGS_SCRIPT = f"""
|
|
21
|
+
$task = Get-ScheduledTask -TaskName '{TASK_NAME}'
|
|
22
|
+
$task.Settings.DisallowStartIfOnBatteries = $false
|
|
23
|
+
$task.Settings.StopIfGoingOnBatteries = $false
|
|
24
|
+
$task.Settings.MultipleInstances = 'IgnoreNew'
|
|
25
|
+
$task.Settings.StartWhenAvailable = $true
|
|
26
|
+
$task | Set-ScheduledTask | Out-Null
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def _foldrive_command():
|
|
30
|
+
"""The command Task Scheduler should run, however foldrive was installed.
|
|
31
|
+
|
|
32
|
+
Always an absolute path: the task runs with a different environment and
|
|
33
|
+
working directory, so a bare `foldrive` may not resolve.
|
|
34
|
+
|
|
35
|
+
Prefers pythonw.exe: it belongs to the GUI subsystem, so Windows never
|
|
36
|
+
allocates a console for it. Running foldrive.exe (a console script) would
|
|
37
|
+
flash a command prompt on screen at every scheduled tick.
|
|
38
|
+
"""
|
|
39
|
+
if getattr(sys, "frozen", False):
|
|
40
|
+
# Standalone foldrive.exe (PyInstaller build; needs --noconsole to be quiet).
|
|
41
|
+
return f'"{sys.executable}" tick'
|
|
42
|
+
|
|
43
|
+
windowless_python = Path(sys.executable).with_name("pythonw.exe")
|
|
44
|
+
if windowless_python.exists():
|
|
45
|
+
return f'"{windowless_python}" -m foldrive.cli tick'
|
|
46
|
+
|
|
47
|
+
installed_path = shutil.which("foldrive")
|
|
48
|
+
if installed_path:
|
|
49
|
+
return f'"{installed_path}" tick'
|
|
50
|
+
|
|
51
|
+
# Installed but not on PATH - call this same interpreter's module directly.
|
|
52
|
+
return f'"{sys.executable}" -m foldrive.cli tick'
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _allow_running_on_battery():
|
|
56
|
+
"""Returns True if the power settings were relaxed.
|
|
57
|
+
|
|
58
|
+
Best-effort: if it fails, the task still syncs whenever the machine is plugged
|
|
59
|
+
in, which is worth keeping rather than failing the whole install over.
|
|
60
|
+
"""
|
|
61
|
+
result = subprocess.run(
|
|
62
|
+
["powershell", "-NoProfile", "-NonInteractive", "-Command", POWER_SETTINGS_SCRIPT],
|
|
63
|
+
capture_output=True,
|
|
64
|
+
text=True,
|
|
65
|
+
)
|
|
66
|
+
return result.returncode == 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def install():
|
|
70
|
+
if sys.platform == "win32":
|
|
71
|
+
return _install_windows()
|
|
72
|
+
return _install_cron()
|
|
73
|
+
|
|
74
|
+
def uninstall():
|
|
75
|
+
if sys.platform == "win32":
|
|
76
|
+
return _uninstall_windows()
|
|
77
|
+
return _uninstall_cron()
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def status():
|
|
81
|
+
if sys.platform == "win32":
|
|
82
|
+
return _status_windows()
|
|
83
|
+
return _status_cron()
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _install_windows():
|
|
88
|
+
subprocess.run([
|
|
89
|
+
"schtasks", "/create", "/tn", TASK_NAME,
|
|
90
|
+
"/tr", _foldrive_command(),
|
|
91
|
+
"/sc", "minute", "/mo", str(INTERVAL_MINUTES),
|
|
92
|
+
"/f",
|
|
93
|
+
], check=True)
|
|
94
|
+
return _allow_running_on_battery()
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _uninstall_windows():
|
|
98
|
+
subprocess.run(["schtasks", "/delete", "/tn", TASK_NAME, "/f"], check=True)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def _status_windows():
|
|
102
|
+
result = subprocess.run(
|
|
103
|
+
["schtasks", "/query", "/tn", TASK_NAME], capture_output=True, text=True
|
|
104
|
+
)
|
|
105
|
+
return result.stdout.strip() if result.returncode == 0 else "not registered"
|
|
106
|
+
|
|
107
|
+
CRON_MARKER = "# foldrive-tick"
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _read_crontab():
|
|
111
|
+
"""Empty string when there is no crontab yet.
|
|
112
|
+
|
|
113
|
+
`crontab -l` exits 1 with "no crontab for <user>" in that case - a normal
|
|
114
|
+
first-run state, not a failure, so this must not use check=True.
|
|
115
|
+
|
|
116
|
+
Any OTHER failure must raise rather than return "": the caller rewrites the
|
|
117
|
+
whole crontab from what this returns, so mistaking an unreadable crontab for
|
|
118
|
+
an empty one would delete every job the user has.
|
|
119
|
+
"""
|
|
120
|
+
result = subprocess.run(["crontab", "-l"], capture_output=True, text=True)
|
|
121
|
+
if result.returncode == 0:
|
|
122
|
+
return result.stdout
|
|
123
|
+
if "no crontab" in result.stderr.lower():
|
|
124
|
+
return ""
|
|
125
|
+
raise SystemExit(f"Could not read the crontab: {result.stderr.strip()}")
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _write_crontab(lines):
|
|
129
|
+
"""cron has no 'append' - the whole crontab is replaced every time. That is
|
|
130
|
+
exactly why every foldrive line carries CRON_MARKER: so we can strip ours and
|
|
131
|
+
put everyone else's back untouched."""
|
|
132
|
+
text = "\n".join(lines).strip() + "\n"
|
|
133
|
+
result = subprocess.run(["crontab", "-"], input=text, text=True, capture_output=True)
|
|
134
|
+
if result.returncode != 0:
|
|
135
|
+
raise SystemExit(f"Could not update crontab: {result.stderr.strip()}")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _without_foldrive_lines(text):
|
|
139
|
+
return [line for line in text.splitlines() if CRON_MARKER not in line]
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _cron_command():
|
|
143
|
+
"""Absolute path: cron runs with a minimal PATH (usually /usr/bin:/bin), so a
|
|
144
|
+
bare `foldrive` may not resolve even though it works in your shell."""
|
|
145
|
+
installed_path = shutil.which("foldrive")
|
|
146
|
+
if installed_path:
|
|
147
|
+
return f"{installed_path} tick"
|
|
148
|
+
return f"{sys.executable} -m foldrive.cli tick"
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _install_cron():
|
|
152
|
+
if shutil.which("crontab") is None:
|
|
153
|
+
raise SystemExit(
|
|
154
|
+
"No `crontab` command found. Install cron, or run `foldrive sync` manually."
|
|
155
|
+
)
|
|
156
|
+
lines = _without_foldrive_lines(_read_crontab())
|
|
157
|
+
# >/dev/null: cron mails the user any output a job produces, and foldrive
|
|
158
|
+
# already writes everything worth keeping to its log file.
|
|
159
|
+
lines.append(
|
|
160
|
+
f"*/{INTERVAL_MINUTES} * * * * {_cron_command()} >/dev/null 2>&1 {CRON_MARKER}"
|
|
161
|
+
)
|
|
162
|
+
_write_crontab(lines)
|
|
163
|
+
return True # cron has no battery restriction to relax
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _uninstall_cron():
|
|
167
|
+
existing = _read_crontab()
|
|
168
|
+
remaining = _without_foldrive_lines(existing)
|
|
169
|
+
if len(remaining) == len(existing.splitlines()):
|
|
170
|
+
raise SystemExit("Background sync was not enabled.")
|
|
171
|
+
_write_crontab(remaining)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _status_cron():
|
|
175
|
+
for line in _read_crontab().splitlines():
|
|
176
|
+
if CRON_MARKER in line:
|
|
177
|
+
return line.strip()
|
|
178
|
+
return "not registered"
|
foldrive/cli.py
ADDED
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
"""foldrive CLI: argument parsing and dispatch. Command logic lives in commands/."""
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
|
|
5
|
+
from . import __version__
|
|
6
|
+
from .commands import (
|
|
7
|
+
autostart, init, log, login, logout, ls, pull, push, restore, setup, status,
|
|
8
|
+
sync, tick, whoami,
|
|
9
|
+
)
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def main():
|
|
13
|
+
parser = argparse.ArgumentParser(
|
|
14
|
+
prog="foldrive",
|
|
15
|
+
description="Pair a local folder with a Google Drive folder.",
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
parser.add_argument(
|
|
19
|
+
"--version",
|
|
20
|
+
action="version",
|
|
21
|
+
version=f"foldrive {__version__}",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
sub = parser.add_subparsers(
|
|
25
|
+
dest="command",
|
|
26
|
+
required=True,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
p = sub.add_parser(
|
|
30
|
+
"setup",
|
|
31
|
+
help="One-time Google setup: install your OAuth client file",
|
|
32
|
+
)
|
|
33
|
+
p.add_argument(
|
|
34
|
+
"path",
|
|
35
|
+
nargs="?",
|
|
36
|
+
default=None,
|
|
37
|
+
help="Path to the client JSON downloaded from Google Cloud "
|
|
38
|
+
"(omit to print the setup steps)",
|
|
39
|
+
)
|
|
40
|
+
p.add_argument(
|
|
41
|
+
"--force",
|
|
42
|
+
action="store_true",
|
|
43
|
+
help="Replace an already-installed client file",
|
|
44
|
+
)
|
|
45
|
+
p.set_defaults(func=setup.run)
|
|
46
|
+
|
|
47
|
+
p = sub.add_parser(
|
|
48
|
+
"login",
|
|
49
|
+
help="Sign in with your Google account",
|
|
50
|
+
)
|
|
51
|
+
p.set_defaults(func=login.run)
|
|
52
|
+
|
|
53
|
+
p = sub.add_parser(
|
|
54
|
+
"whoami",
|
|
55
|
+
help="Show the currently signed-in Google account",
|
|
56
|
+
)
|
|
57
|
+
p.set_defaults(func=whoami.run)
|
|
58
|
+
|
|
59
|
+
p = sub.add_parser(
|
|
60
|
+
"logout",
|
|
61
|
+
help="Sign out and forget the saved Google token",
|
|
62
|
+
)
|
|
63
|
+
p.set_defaults(func=logout.run)
|
|
64
|
+
|
|
65
|
+
p = sub.add_parser(
|
|
66
|
+
"ls",
|
|
67
|
+
help="List the contents of a Drive folder by name",
|
|
68
|
+
)
|
|
69
|
+
p.add_argument(
|
|
70
|
+
"name",
|
|
71
|
+
nargs="?",
|
|
72
|
+
default=None,
|
|
73
|
+
help="Drive folder name to look up (you'll be asked if omitted)",
|
|
74
|
+
)
|
|
75
|
+
p.set_defaults(func=ls.run)
|
|
76
|
+
|
|
77
|
+
p = sub.add_parser(
|
|
78
|
+
"init",
|
|
79
|
+
help="Initialize the current folder for foldrive syncing",
|
|
80
|
+
)
|
|
81
|
+
p.set_defaults(func=init.run)
|
|
82
|
+
|
|
83
|
+
p = sub.add_parser(
|
|
84
|
+
"status",
|
|
85
|
+
help="Show local and remote sync status",
|
|
86
|
+
)
|
|
87
|
+
p.add_argument(
|
|
88
|
+
"--all",
|
|
89
|
+
action="store_true",
|
|
90
|
+
help="List every pending file instead of the first 40",
|
|
91
|
+
)
|
|
92
|
+
p.set_defaults(func=status.run)
|
|
93
|
+
|
|
94
|
+
p = sub.add_parser(
|
|
95
|
+
"push",
|
|
96
|
+
help="Upload local changes to Google Drive",
|
|
97
|
+
)
|
|
98
|
+
p.add_argument(
|
|
99
|
+
"--yes",
|
|
100
|
+
action="store_true",
|
|
101
|
+
help="Skip the first-sync confirmation prompt",
|
|
102
|
+
)
|
|
103
|
+
p.add_argument(
|
|
104
|
+
"--allow-mass-delete",
|
|
105
|
+
action="store_true",
|
|
106
|
+
help="Permit a run that would delete most of the folder",
|
|
107
|
+
)
|
|
108
|
+
p.set_defaults(func=push.run)
|
|
109
|
+
|
|
110
|
+
p = sub.add_parser(
|
|
111
|
+
"pull",
|
|
112
|
+
help="Download remote changes from Google Drive",
|
|
113
|
+
)
|
|
114
|
+
p.add_argument(
|
|
115
|
+
"--yes",
|
|
116
|
+
action="store_true",
|
|
117
|
+
help="Never prompt; use safe defaults",
|
|
118
|
+
)
|
|
119
|
+
p.add_argument(
|
|
120
|
+
"--allow-mass-delete",
|
|
121
|
+
action="store_true",
|
|
122
|
+
help="Permit a run that would delete most of the folder",
|
|
123
|
+
)
|
|
124
|
+
p.set_defaults(func=pull.run)
|
|
125
|
+
|
|
126
|
+
p = sub.add_parser(
|
|
127
|
+
"sync",
|
|
128
|
+
help="Synchronize local and Google Drive changes",
|
|
129
|
+
)
|
|
130
|
+
p.add_argument(
|
|
131
|
+
"--yes",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="Never prompt; use safe defaults",
|
|
134
|
+
)
|
|
135
|
+
p.add_argument(
|
|
136
|
+
"--allow-mass-delete",
|
|
137
|
+
action="store_true",
|
|
138
|
+
help="Permit a run that would delete most of the folder",
|
|
139
|
+
)
|
|
140
|
+
p.set_defaults(func=sync.run)
|
|
141
|
+
|
|
142
|
+
p = sub.add_parser(
|
|
143
|
+
"log",
|
|
144
|
+
help="Show what foldrive has done to this folder",
|
|
145
|
+
)
|
|
146
|
+
p.add_argument(
|
|
147
|
+
"-n", "--number",
|
|
148
|
+
type=int,
|
|
149
|
+
default=20,
|
|
150
|
+
help="How many entries to show (default 20)",
|
|
151
|
+
)
|
|
152
|
+
p.add_argument(
|
|
153
|
+
"--all",
|
|
154
|
+
action="store_true",
|
|
155
|
+
help="Show the entire history",
|
|
156
|
+
)
|
|
157
|
+
p.set_defaults(func=log.run)
|
|
158
|
+
|
|
159
|
+
p = sub.add_parser(
|
|
160
|
+
"restore",
|
|
161
|
+
help="Bring one file back from Drive",
|
|
162
|
+
)
|
|
163
|
+
p.add_argument(
|
|
164
|
+
"path",
|
|
165
|
+
help="Path of the file inside this folder, e.g. notes/unit-3.pdf",
|
|
166
|
+
)
|
|
167
|
+
p.add_argument(
|
|
168
|
+
"--force",
|
|
169
|
+
action="store_true",
|
|
170
|
+
help="Overwrite the local file if it already exists",
|
|
171
|
+
)
|
|
172
|
+
p.set_defaults(func=restore.run)
|
|
173
|
+
|
|
174
|
+
p = sub.add_parser(
|
|
175
|
+
"tick",
|
|
176
|
+
help="Run any scheduled syncs that are due (used by the background task)",
|
|
177
|
+
)
|
|
178
|
+
p.set_defaults(func=tick.run)
|
|
179
|
+
|
|
180
|
+
p = sub.add_parser(
|
|
181
|
+
"autostart",
|
|
182
|
+
help="Enable foldrive to start automatically when you log in",
|
|
183
|
+
)
|
|
184
|
+
p.add_argument("--remove", action="store_true",help="Disable background sync")
|
|
185
|
+
p.add_argument("--status",action="store_true",help="Show whether it's registered")
|
|
186
|
+
p.set_defaults(func=autostart.run)
|
|
187
|
+
|
|
188
|
+
args = parser.parse_args()
|
|
189
|
+
try:
|
|
190
|
+
args.func(args)
|
|
191
|
+
except KeyboardInterrupt:
|
|
192
|
+
# Ctrl-C is a normal way to stop a long sync, not a crash. Each command
|
|
193
|
+
# saves its snapshot in a `finally`, so whatever finished is already
|
|
194
|
+
# recorded and the next run picks up from there.
|
|
195
|
+
raise SystemExit("\nInterrupted. Finished files are saved - re-run to continue.")
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
if __name__ == "__main__":
|
|
199
|
+
main()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""One module per CLI command; each exposes run(args)."""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .. import autostart,logs
|
|
2
|
+
|
|
3
|
+
def run(args):
|
|
4
|
+
if(args.remove):
|
|
5
|
+
autostart.uninstall()
|
|
6
|
+
print("Background Sync Disabled")
|
|
7
|
+
return
|
|
8
|
+
if args.status:
|
|
9
|
+
print(autostart.status())
|
|
10
|
+
return
|
|
11
|
+
runs_on_battery = autostart.install()
|
|
12
|
+
print(f"Background sync enabled - foldrive tick runs every {autostart.INTERVAL_MINUTES} minutes")
|
|
13
|
+
if not runs_on_battery:
|
|
14
|
+
print("Note: could not allow syncing on battery power - Windows will only")
|
|
15
|
+
print(" run the background sync while this machine is plugged in.")
|
|
16
|
+
print(f"Log: {logs.LOG_PATH}")
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import copy
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
|
|
4
|
+
from googleapiclient.errors import HttpError
|
|
5
|
+
|
|
6
|
+
from .. import auth, config, drive
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def run(args):
|
|
10
|
+
folder = Path.cwd()
|
|
11
|
+
|
|
12
|
+
if (folder / config.CONFIG_NAME).exists():
|
|
13
|
+
raise SystemExit("Already initialized. Edit .googledrive.json to change settings.")
|
|
14
|
+
|
|
15
|
+
enclosing_root = config.find_config_root(folder.parent)
|
|
16
|
+
if enclosing_root is not None:
|
|
17
|
+
raise SystemExit(f"This folder is inside '{enclosing_root}', which is already synced.")
|
|
18
|
+
|
|
19
|
+
service = auth.get_service()
|
|
20
|
+
try:
|
|
21
|
+
matching_folders = drive.find_folder_by_name(service, folder.name)
|
|
22
|
+
|
|
23
|
+
if len(matching_folders) > 1:
|
|
24
|
+
print(f"Multiple Drive folders are named '{folder.name}':")
|
|
25
|
+
for folder_match in matching_folders:
|
|
26
|
+
print(f" {folder_match['name']} (id: {folder_match['id']})")
|
|
27
|
+
raise SystemExit(
|
|
28
|
+
"foldrive can't tell them apart yet. Rename the extras in Drive, then re-run init."
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
elif len(matching_folders) == 1:
|
|
32
|
+
drive_folder_id = matching_folders[0]["id"]
|
|
33
|
+
answer = input(f"Found Drive folder '{folder.name}'. Link this local folder to it? [y/N] ")
|
|
34
|
+
if answer.strip().lower() != "y":
|
|
35
|
+
raise SystemExit("Cancelled. Nothing was created or linked.")
|
|
36
|
+
|
|
37
|
+
else:
|
|
38
|
+
answer = input(f"No Drive folder named '{folder.name}' found. Create it in My Drive? [y/N] ")
|
|
39
|
+
if answer.strip().lower() != "y":
|
|
40
|
+
raise SystemExit("Cancelled. Nothing was created or linked.")
|
|
41
|
+
drive_folder_id = drive.create_folder(service, folder.name)
|
|
42
|
+
print(f"Created Drive folder '{folder.name}'.")
|
|
43
|
+
|
|
44
|
+
new_config = copy.deepcopy(config.DEFAULT_CONFIG)
|
|
45
|
+
new_config["drive_folder_id"] = drive_folder_id
|
|
46
|
+
new_config["drive_folder_name"] = folder.name
|
|
47
|
+
config.save_config(folder, new_config)
|
|
48
|
+
config.register_folder(folder)
|
|
49
|
+
|
|
50
|
+
print(f"Linked '{folder}' <-> Drive folder '{folder.name}'.")
|
|
51
|
+
print("Settings saved in .googledrive.json — edit schedules/ignores there.")
|
|
52
|
+
print("Next: run `foldrive status` to see what a first sync would do.")
|
|
53
|
+
|
|
54
|
+
except HttpError as api_error:
|
|
55
|
+
raise SystemExit(f"Google Drive API error: {api_error.reason}")
|
|
56
|
+
except OSError:
|
|
57
|
+
raise SystemExit("Network error — are you connected to the internet?")
|
foldrive/commands/log.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
"""Show what foldrive did to this folder, most recent first."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from .. import config, history
|
|
7
|
+
|
|
8
|
+
# Past tense: this is a record of what happened, not a plan.
|
|
9
|
+
KIND_LABELS = {
|
|
10
|
+
"upload_new": "uploaded",
|
|
11
|
+
"upload_changed": "updated in Drive",
|
|
12
|
+
"download_new": "downloaded",
|
|
13
|
+
"download_changed": "updated locally",
|
|
14
|
+
"trash_remote": "trashed in Drive",
|
|
15
|
+
"recycle_local": "moved to Recycle Bin",
|
|
16
|
+
"link": "linked",
|
|
17
|
+
"download_new_doc": "downloaded Doc",
|
|
18
|
+
"download_changed_doc": "downloaded Doc again",
|
|
19
|
+
"download_changed_doc_keep_local": "downloaded Doc, kept local copy",
|
|
20
|
+
"upload_changed_doc": "uploaded into Doc",
|
|
21
|
+
"upload_changed_doc_keep_drive": "uploaded into Doc, kept Drive copy",
|
|
22
|
+
"restore": "restored from Drive",
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
# A gap this long means a different run - blank line between runs so the history
|
|
26
|
+
# reads as sessions rather than one undifferentiated stream.
|
|
27
|
+
RUN_GAP_SECONDS = 120
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _local_time(iso_text):
|
|
31
|
+
try:
|
|
32
|
+
return datetime.fromisoformat(iso_text).astimezone()
|
|
33
|
+
except (ValueError, TypeError):
|
|
34
|
+
return None
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def run(args):
|
|
38
|
+
folder = config.find_config_root(Path.cwd())
|
|
39
|
+
if folder is None:
|
|
40
|
+
raise SystemExit("Not a foldrive folder. Run: foldrive init")
|
|
41
|
+
|
|
42
|
+
limit = None if args.all else args.number
|
|
43
|
+
entries = history.read(folder, limit)
|
|
44
|
+
|
|
45
|
+
if not entries:
|
|
46
|
+
print("No history yet for this folder.")
|
|
47
|
+
print("It is written as foldrive syncs; run `foldrive sync` first.")
|
|
48
|
+
return
|
|
49
|
+
|
|
50
|
+
print(f"Folder : {folder}")
|
|
51
|
+
print(f"History: {history.history_path(folder)}\n")
|
|
52
|
+
|
|
53
|
+
previous_time = None
|
|
54
|
+
for entry in entries:
|
|
55
|
+
moment = _local_time(entry.get("at"))
|
|
56
|
+
if (previous_time and moment
|
|
57
|
+
and (previous_time - moment).total_seconds() > RUN_GAP_SECONDS):
|
|
58
|
+
print()
|
|
59
|
+
previous_time = moment
|
|
60
|
+
|
|
61
|
+
stamp = moment.strftime("%Y-%m-%d %H:%M:%S") if moment else "unknown time"
|
|
62
|
+
label = KIND_LABELS.get(entry.get("kind"), entry.get("kind", "?"))
|
|
63
|
+
print(f" {stamp} {label:<28} {entry.get('path', '')}")
|
|
64
|
+
|
|
65
|
+
if not args.all:
|
|
66
|
+
print(f"\nShowing the last {len(entries)}. Use --all for everything, "
|
|
67
|
+
f"-n N for a different count.")
|
foldrive/commands/ls.py
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
from googleapiclient.errors import HttpError
|
|
2
|
+
|
|
3
|
+
from .. import auth, drive
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def run(args):
|
|
7
|
+
if args.name is None:
|
|
8
|
+
args.name = input("Enter the Drive folder name: ").strip()
|
|
9
|
+
if not args.name:
|
|
10
|
+
raise SystemExit("No folder name given.")
|
|
11
|
+
|
|
12
|
+
service = auth.get_service()
|
|
13
|
+
try:
|
|
14
|
+
matching_folders = drive.find_folder_by_name(service, args.name)
|
|
15
|
+
if not matching_folders:
|
|
16
|
+
print(f"No Drive folder named '{args.name}' found.")
|
|
17
|
+
return
|
|
18
|
+
if len(matching_folders) > 1:
|
|
19
|
+
print(f"Multiple folders named '{args.name}' found.")
|
|
20
|
+
for folder_match in matching_folders:
|
|
21
|
+
print(f" {folder_match['name']} (id: {folder_match['id']})")
|
|
22
|
+
return
|
|
23
|
+
|
|
24
|
+
children = drive.list_children(service, matching_folders[0]["id"])
|
|
25
|
+
if not children:
|
|
26
|
+
print("(empty folder)")
|
|
27
|
+
return
|
|
28
|
+
folders_first_alphabetical = sorted(
|
|
29
|
+
children,
|
|
30
|
+
key=lambda child: (child["mimeType"] != drive.FOLDER_MIME_TYPE, child["name"].lower()),
|
|
31
|
+
)
|
|
32
|
+
print(f"Found one folder with the name '{args.name}', following are the contents in Google Drive of that folder: ")
|
|
33
|
+
for child in folders_first_alphabetical:
|
|
34
|
+
if child["mimeType"] == drive.FOLDER_MIME_TYPE:
|
|
35
|
+
print(f" {child['name']}/")
|
|
36
|
+
else:
|
|
37
|
+
size_kb = int(child.get("size", 0)) // 1024
|
|
38
|
+
print(f" {child['name']} ({size_kb} KB)")
|
|
39
|
+
except HttpError as api_error:
|
|
40
|
+
raise SystemExit(f"Google Drive API error: {api_error.reason}")
|
|
41
|
+
except OSError:
|
|
42
|
+
raise SystemExit("Network error — are you connected to the internet?")
|