Skip to content

Commit 23b66a0

Browse files
authored
Merge pull request #58 from ryancheley/feature/since-table-logic
Implement automatic since table logic for incremental updates Closes #14
2 parents 6d85260 + 22cf5e3 commit 23b66a0

File tree

6 files changed

+576
-14
lines changed

6 files changed

+576
-14
lines changed

src/toggl_to_sqlite/_version.py

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55

66
TYPE_CHECKING = False
77
if TYPE_CHECKING:
8-
from typing import Tuple, Union
8+
from typing import Tuple
9+
from typing import Union
910

1011
VERSION_TUPLE = Tuple[Union[int, str], ...]
1112
else:
@@ -16,5 +17,5 @@
1617
__version_tuple__: VERSION_TUPLE
1718
version_tuple: VERSION_TUPLE
1819

19-
__version__ = version = "0.8.1.dev0+g7f9fa80.d20250615"
20-
__version_tuple__ = version_tuple = (0, 8, 1, "dev0", "g7f9fa80.d20250615")
20+
__version__ = version = '0.8.6.dev0+g6d85260.d20250615'
21+
__version_tuple__ = version_tuple = (0, 8, 6, 'dev0', 'g6d85260.d20250615')

src/toggl_to_sqlite/cli.py

Lines changed: 42 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -54,27 +54,58 @@ def auth(auth):
5454
default="auth.json",
5555
help="Path to auth tokens, defaults to auth.json",
5656
)
57-
@click.option("-d", "--days", required=True, type=int, default=25)
58-
@click.option("-s", "--since", type=click.DateTime())
5957
@click.option(
60-
"-t",
61-
"--type",
62-
default=["time_entries", "workspaces", "projects"],
63-
required=True,
64-
multiple=True,
58+
"-d", "--days", type=int, default=25, help="Number of days to fetch (only used if no automatic since date is available)"
59+
)
60+
@click.option("-s", "--since", type=click.DateTime(), help="Fetch data since this date (overrides automatic since detection)")
61+
@click.option("--force-full", is_flag=True, help="Force a full sync, ignoring previous sync times")
62+
@click.option(
63+
"-t", "--type", default=["time_entries", "workspaces", "projects"], required=True, multiple=True, help="Data types to fetch"
6564
)
66-
def fetch(db_path, auth, days, since, type):
65+
def fetch(db_path, auth, days, since, force_full, type):
6766
"Save Toggl data to a SQLite database"
67+
import datetime
68+
6869
auth = json.load(open(auth))
6970
db = sqlite_utils.Database(db_path)
70-
days = days
71-
since = since
71+
72+
# Get current time for updating sync timestamps
73+
sync_time = datetime.datetime.now(datetime.timezone.utc)
74+
7275
if "time_entries" in type:
73-
time_entries = utils.get_time_entries(api_token=auth["api_token"], days=days, since=since)
76+
# Use automatic since detection for time entries (unless force_full is specified)
77+
if force_full:
78+
click.echo("Force full sync requested - fetching all time entries")
79+
effective_since = None
80+
else:
81+
effective_since = utils.get_effective_since_date(
82+
api_token=auth["api_token"], table_name="time_entries", db=db, user_since=since
83+
)
84+
85+
# Only use automatic since if no explicit since date is provided
86+
if effective_since and not since and not force_full:
87+
# Convert to date for comparison if it's a datetime
88+
effective_date = effective_since.date() if hasattr(effective_since, "date") else effective_since
89+
days_since_effective = (datetime.datetime.now().date() - effective_date).days + 1 # Add 1 to ensure overlap
90+
click.echo(f"📅 Fetching time entries since {effective_date} ({days_since_effective} days)")
91+
time_entries = utils.get_time_entries(api_token=auth["api_token"], days=days_since_effective, since=effective_since)
92+
else:
93+
if since:
94+
since_date = since.date() if hasattr(since, "date") else since
95+
click.echo(f"📅 Fetching time entries since user-specified date: {since_date}")
96+
else:
97+
click.echo(f"📅 Fetching time entries for the last {days} days")
98+
time_entries = utils.get_time_entries(api_token=auth["api_token"], days=days, since=since)
99+
74100
utils.save_items(time_entries, "time_entries", db)
101+
utils.update_sync_time(db, "time_entries", sync_time)
102+
75103
if "workspaces" in type:
76104
workspaces = utils.get_workspaces(api_token=auth["api_token"])
77105
utils.save_items(workspaces, "workspaces", db)
106+
utils.update_sync_time(db, "workspaces", sync_time)
107+
78108
if "projects" in type:
79109
projects = utils.get_projects(api_token=auth["api_token"])
80110
utils.save_items(projects, "projects", db)
111+
utils.update_sync_time(db, "projects", sync_time)

src/toggl_to_sqlite/utils.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,3 +75,50 @@ def save_items(items: list, table: str, db: sqlite_utils.Database) -> None:
7575
db[table].insert_all(data, pk="id", alter=True, replace=True)
7676
except AttributeError:
7777
print(item)
78+
79+
80+
def get_last_sync_time(db: sqlite_utils.Database, table_name: str) -> datetime.datetime:
81+
"""Get the last sync time for a specific table."""
82+
since_table = f"{table_name}_since"
83+
84+
if since_table not in db.table_names():
85+
return None
86+
87+
try:
88+
# Explicitly query for row with id=1
89+
rows = list(db[since_table].rows_where("id = ?", [1]))
90+
if not rows:
91+
return None
92+
row = rows[0]
93+
return datetime.datetime.fromisoformat(row["since"])
94+
except (sqlite_utils.db.NotFoundError, KeyError, IndexError, ValueError):
95+
return None
96+
97+
98+
def update_sync_time(db: sqlite_utils.Database, table_name: str, sync_time: datetime.datetime) -> None:
99+
"""Update the last sync time for a specific table."""
100+
since_table = f"{table_name}_since"
101+
102+
db[since_table].insert({"id": 1, "since": sync_time.isoformat()}, pk="id", replace=True, alter=True)
103+
104+
105+
def get_effective_since_date(
106+
api_token: str, table_name: str, db: sqlite_utils.Database, user_since: datetime.datetime = None
107+
) -> datetime.datetime:
108+
"""Get the effective 'since' date to use for fetching data.
109+
110+
Priority:
111+
1. User-provided since parameter
112+
2. Last sync time from database
113+
3. Workspace creation date (fallback)
114+
"""
115+
if user_since:
116+
return user_since
117+
118+
# Check for last sync time
119+
last_sync = get_last_sync_time(db, table_name)
120+
if last_sync:
121+
return last_sync
122+
123+
# Fallback to workspace creation date
124+
return get_start_datetime(api_token)

tests/test_cli_auth.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
"""Tests for the CLI auth command."""
2+
3+
import json
4+
import os
5+
import tempfile
6+
from unittest import mock
7+
8+
from click.testing import CliRunner
9+
10+
from toggl_to_sqlite.cli import cli
11+
12+
13+
def test_auth_command_creates_file():
14+
"""Test that auth command creates auth file with API token."""
15+
runner = CliRunner()
16+
17+
with tempfile.TemporaryDirectory() as temp_dir:
18+
auth_file = os.path.join(temp_dir, "test_auth.json")
19+
20+
# Mock the input function to provide a test API token
21+
with mock.patch("builtins.input", return_value="test_api_token_12345"):
22+
result = runner.invoke(cli, ["auth", "--auth", auth_file])
23+
24+
assert result.exit_code == 0
25+
assert "Visit this page and sign in with your Toggl account:" in result.output
26+
assert "https://track.toggl.com/profile" in result.output
27+
assert f"Authentication tokens written to {auth_file}" in result.output
28+
29+
# Verify the file was created with correct content
30+
assert os.path.exists(auth_file)
31+
32+
with open(auth_file, "r") as f:
33+
auth_data = json.load(f)
34+
35+
assert auth_data == {"api_token": "test_api_token_12345"}
36+
37+
38+
def test_auth_command_default_file():
39+
"""Test that auth command uses default auth.json filename."""
40+
runner = CliRunner()
41+
42+
with tempfile.TemporaryDirectory() as temp_dir:
43+
# Change to temp directory so auth.json is created there
44+
original_cwd = os.getcwd()
45+
os.chdir(temp_dir)
46+
47+
try:
48+
with mock.patch("builtins.input", return_value="default_token"):
49+
result = runner.invoke(cli, ["auth"])
50+
51+
assert result.exit_code == 0
52+
assert "Authentication tokens written to auth.json" in result.output
53+
54+
# Verify default file was created
55+
assert os.path.exists("auth.json")
56+
57+
with open("auth.json", "r") as f:
58+
auth_data = json.load(f)
59+
60+
assert auth_data["api_token"] == "default_token"
61+
finally:
62+
os.chdir(original_cwd)
63+
64+
65+
def test_auth_command_overwrites_existing():
66+
"""Test that auth command overwrites existing auth file."""
67+
runner = CliRunner()
68+
69+
with tempfile.TemporaryDirectory() as temp_dir:
70+
auth_file = os.path.join(temp_dir, "existing_auth.json")
71+
72+
# Create existing auth file
73+
with open(auth_file, "w") as f:
74+
json.dump({"api_token": "old_token"}, f)
75+
76+
# Run auth command with new token
77+
with mock.patch("builtins.input", return_value="new_token_54321"):
78+
result = runner.invoke(cli, ["auth", "--auth", auth_file])
79+
80+
assert result.exit_code == 0
81+
82+
# Verify file was overwritten
83+
with open(auth_file, "r") as f:
84+
auth_data = json.load(f)
85+
86+
assert auth_data["api_token"] == "new_token_54321"
87+
88+
89+
def test_auth_command_shows_instructions():
90+
"""Test that auth command displays proper user instructions."""
91+
runner = CliRunner()
92+
93+
with tempfile.TemporaryDirectory() as temp_dir:
94+
auth_file = os.path.join(temp_dir, "instructions_test.json")
95+
96+
with mock.patch("builtins.input", return_value="instruction_token"):
97+
result = runner.invoke(cli, ["auth", "--auth", auth_file])
98+
99+
# Check that expected instruction text is present
100+
output = result.output
101+
assert "Visit this page and sign in with your Toggl account:" in output
102+
assert "https://track.toggl.com/profile" in output
103+
assert f"Authentication tokens written to {auth_file}" in output

0 commit comments

Comments
 (0)