Skip to content

Commit 53a7437

Browse files
committed
save paths in db + clear cache when they change
1 parent e42135c commit 53a7437

6 files changed

Lines changed: 301 additions & 9 deletions

File tree

src/redfetch/main.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ def initialize_db_only():
5959
auth.initialize_keyring()
6060
auth.authorize()
6161
db_name = f"{config.settings.ENV}_resources.db"
62-
store.initialize_db(db_name)
62+
store.initialize_db(db_name, config.settings.ENV)
6363
db_path = store.get_db_path(db_name)
6464
return db_name, db_path
6565

@@ -349,6 +349,9 @@ def config_command(
349349
config.initialize_config()
350350
setting_path_list = path.split('.')
351351
config.update_setting(setting_path_list, value, server.value if server else None)
352+
settings_env = server.value if server else config.settings.ENV
353+
db_name = f"{settings_env}_resources.db"
354+
store.reconcile_install_signature(db_name, settings_env)
352355
console.print(f"Updated setting {path} to {value}{' for server ' + server.value if server else ''}.")
353356

354357

src/redfetch/store.py

Lines changed: 117 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,13 @@
11
from collections.abc import Iterable
2+
import json
23
import os
34
import sqlite3
45
import aiosqlite
56

67
from redfetch import config
78
from redfetch.models import DownloadTask, Resource
89
from redfetch import meta
10+
from redfetch import utils
911

1012
# Unified schema version marker
1113
SCHEMA_VERSION = 1
@@ -44,15 +46,19 @@ def get_db_path(db_name: str) -> str:
4446
return os.path.join(_get_cache_dir(), db_name)
4547

4648

47-
def initialize_db(db_name: str) -> None:
49+
def initialize_db(db_name: str, settings_env: str):
4850
"""Ensure unified schema; reset once to unified schema if version is outdated."""
4951
with get_db_connection(db_name) as conn:
5052
cursor = conn.cursor()
51-
_ensure_metadata(cursor)
52-
_ensure_downloads_table(cursor)
53-
_normalize_parent_ids(cursor)
54-
_ensure_indexes(cursor)
55-
_ensure_navmesh_tables(cursor)
53+
_initialize_schema(cursor)
54+
return _reconcile_install_signature(cursor, settings_env)
55+
56+
57+
def reconcile_install_signature(db_name: str, settings_env: str):
58+
with get_db_connection(db_name) as conn:
59+
cursor = conn.cursor()
60+
_initialize_schema(cursor)
61+
return _reconcile_install_signature(cursor, settings_env)
5662

5763

5864
def _ensure_downloads_table(cursor) -> None:
@@ -99,6 +105,8 @@ def _ensure_metadata(cursor) -> None:
99105
if 'schema_version' not in cols:
100106
cursor.execute("ALTER TABLE metadata ADD COLUMN schema_version INTEGER")
101107
cursor.execute("UPDATE metadata SET schema_version=0 WHERE id=1")
108+
if 'install_signature' not in cols:
109+
cursor.execute("ALTER TABLE metadata ADD COLUMN install_signature TEXT")
102110
# Check current version and reset schema if outdated
103111
cursor.execute("SELECT schema_version FROM metadata WHERE id=1")
104112
row = cursor.fetchone()
@@ -107,6 +115,109 @@ def _ensure_metadata(cursor) -> None:
107115
_reset_to_unified_schema(cursor)
108116

109117

118+
def _initialize_schema(cursor) -> None:
119+
_ensure_metadata(cursor)
120+
_ensure_downloads_table(cursor)
121+
_normalize_parent_ids(cursor)
122+
_ensure_indexes(cursor)
123+
_ensure_navmesh_tables(cursor)
124+
125+
126+
def _get_vvmq_id_for_env(settings_env: str) -> str | None:
127+
for resource_id, env in config.VANILLA_MAP.items():
128+
if env.upper() == settings_env.upper():
129+
return str(resource_id)
130+
return None
131+
132+
133+
def _compute_install_signature(settings_env: str) -> dict:
134+
settings_for_env = config.settings.from_env(settings_env)
135+
download_folder = os.path.normpath(settings_for_env.DOWNLOAD_FOLDER)
136+
eqpath = os.path.normpath(settings_for_env.EQPATH) if settings_for_env.EQPATH else ""
137+
138+
vvmq_id = _get_vvmq_id_for_env(settings_env)
139+
vvmq_path = None
140+
if vvmq_id:
141+
vvmq_resource = settings_for_env.SPECIAL_RESOURCES.get(vvmq_id)
142+
vvmq_path = utils.resolve_special_destination(vvmq_resource, download_folder)
143+
144+
base_path = vvmq_path if vvmq_path else download_folder
145+
146+
special_destinations: dict[str, str] = {}
147+
for resource_id, resource_info in settings_for_env.SPECIAL_RESOURCES.items():
148+
destination = utils.resolve_special_destination(resource_info, download_folder)
149+
if destination:
150+
special_destinations[str(resource_id)] = destination
151+
152+
return {
153+
"base_path": base_path,
154+
"download_folder": download_folder,
155+
"eqpath": eqpath,
156+
"special_destinations": special_destinations,
157+
}
158+
159+
160+
def _load_install_signature(cursor) -> dict | None:
161+
cursor.execute("SELECT install_signature FROM metadata WHERE id = 1")
162+
row = cursor.fetchone()
163+
if not row or row[0] is None:
164+
return None
165+
return json.loads(row[0])
166+
167+
168+
def _save_install_signature(cursor, signature: dict) -> None:
169+
cursor.execute(
170+
"UPDATE metadata SET install_signature = ? WHERE id = 1",
171+
(json.dumps(signature, sort_keys=True),),
172+
)
173+
174+
175+
def _diff_special_destinations(before: dict | None, after: dict) -> list[str]:
176+
before_map = before.get("special_destinations", {}) if before else {}
177+
after_map = after.get("special_destinations", {})
178+
changed = []
179+
for resource_id in set(before_map) | set(after_map):
180+
if before_map.get(resource_id) != after_map.get(resource_id):
181+
changed.append(str(resource_id))
182+
return sorted(changed)
183+
184+
185+
def _reset_download_dates_for_special_resource(cursor, resource_id: str) -> None:
186+
rid_int = int(resource_id)
187+
cursor.execute(
188+
"UPDATE downloads SET version_local=0 WHERE parent_id = 0 AND resource_id = ?",
189+
(rid_int,),
190+
)
191+
cursor.execute(
192+
"UPDATE downloads SET version_local=0 WHERE parent_id = ?",
193+
(rid_int,),
194+
)
195+
196+
197+
def _reconcile_install_signature(cursor, settings_env: str) -> dict | None:
198+
before = _load_install_signature(cursor)
199+
after = _compute_install_signature(settings_env)
200+
201+
if before is None:
202+
_save_install_signature(cursor, after)
203+
return None
204+
205+
if before == after:
206+
return None
207+
208+
if before.get("base_path") != after.get("base_path"):
209+
reset_download_dates(cursor)
210+
_save_install_signature(cursor, after)
211+
return {"global_reset": True, "changed_ids": []}
212+
213+
changed_ids = _diff_special_destinations(before, after)
214+
for resource_id in changed_ids:
215+
_reset_download_dates_for_special_resource(cursor, resource_id)
216+
217+
_save_install_signature(cursor, after)
218+
return {"global_reset": False, "changed_ids": changed_ids}
219+
220+
110221
def _ensure_indexes(cursor) -> None:
111222
"""Create indexes."""
112223
try:

src/redfetch/terminal_ui.py

Lines changed: 20 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1362,6 +1362,7 @@ def handle_input_update(self, input_id: str, input_value: str) -> None:
13621362
settings_tab = main_screen.query_one(SettingsTab)
13631363
settings_tab.update_vvmq_path_display()
13641364
self.notify("Download folder updated" if input_value else "Download folder cleared")
1365+
self._queue_signature_reconcile()
13651366
except ValidationError as e:
13661367
self.notify(f"Invalid Download Folder: {e}", severity="error")
13671368
elif input_id == "eq_path_input":
@@ -1374,6 +1375,7 @@ def handle_input_update(self, input_id: str, input_value: str) -> None:
13741375
eq_maps_select = main_screen.query_one("#eq_maps", Select)
13751376
eq_maps_select.disabled = not bool(input_value)
13761377
eq_maps_select.value = self.get_current_eq_maps_value()
1378+
self._queue_signature_reconcile()
13771379

13781380
except ValidationError as e:
13791381
self.notify(f"Invalid EverQuest Path: {e}", severity="error")
@@ -1385,6 +1387,7 @@ def handle_input_update(self, input_id: str, input_value: str) -> None:
13851387
try:
13861388
config.update_setting(['SPECIAL_RESOURCES', vvmq_id, 'custom_path'], input_value, env=self.current_env)
13871389
self.notify("Very Vanilla MQ folder updated" if input_value else "Very Vanilla MQ folder cleared")
1390+
self._queue_signature_reconcile()
13881391
except ValidationError as e:
13891392
self.notify(f"Invalid VVMQ Path: {e}", severity="error")
13901393

@@ -1425,6 +1428,22 @@ def update_selected_directory(self, selected_path: Path | None, input_id: str) -
14251428
else:
14261429
self.notify("No directory selected", severity="warning")
14271430

1431+
def _queue_signature_reconcile(self) -> None:
1432+
if self.is_updating:
1433+
return
1434+
self._reconcile_signature_worker()
1435+
1436+
@work(exclusive=True, group="signature_reconcile")
1437+
async def _reconcile_signature_worker(self) -> None:
1438+
db_name = f"{self.current_env}_resources.db"
1439+
result = await asyncio.to_thread(store.reconcile_install_signature, db_name, self.current_env)
1440+
if result:
1441+
if result.get("global_reset"):
1442+
message = f"Cache cleared for {self.current_env}; next update will re-download into the new path."
1443+
else:
1444+
message = f"Cache cleared for {self.current_env}; next update will re-download updated destinations."
1445+
self.notify(message)
1446+
14281447
#
14291448
# Toggle handlers
14301449
#
@@ -1863,7 +1882,7 @@ def _process_sync_event(self, event_type: str, resource_id: str | int, details:
18631882
async def run_synchronization(self, resource_ids=None, navmesh_override=None):
18641883
try:
18651884
db_name = f"{self.current_env}_resources.db"
1866-
await asyncio.to_thread(store.initialize_db, db_name)
1885+
await asyncio.to_thread(store.initialize_db, db_name, self.current_env)
18671886
db_path = store.get_db_path(db_name)
18681887
headers = await api.get_api_headers()
18691888
if resource_ids:

src/redfetch/utils.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,19 @@ def ensure_directory_exists(path):
5858
raise
5959

6060

61+
def resolve_special_destination(special_resource: dict, download_folder: str) -> str | None:
62+
"""Resolve a special resource destination path without side effects."""
63+
if not special_resource:
64+
return None
65+
custom_path = special_resource.get("custom_path")
66+
if custom_path:
67+
return os.path.normpath(os.path.realpath(custom_path))
68+
default_path = special_resource.get("default_path")
69+
if default_path:
70+
return os.path.normpath(os.path.join(download_folder, default_path))
71+
return None
72+
73+
6174
def get_special_resource_path(resource_id):
6275
"""Get the path for special resources."""
6376
special_resource = config.settings.from_env(config.settings.ENV).SPECIAL_RESOURCES.get(resource_id)

tests/test_path_reset.py

Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import os
2+
from types import SimpleNamespace
3+
from unittest.mock import MagicMock
4+
5+
from redfetch import config, store, main
6+
7+
8+
def _set_env_settings(download_folder: str, eqpath: str, special_resources: dict) -> None:
9+
env_settings = SimpleNamespace(
10+
DOWNLOAD_FOLDER=download_folder,
11+
EQPATH=eqpath,
12+
SPECIAL_RESOURCES=special_resources,
13+
)
14+
config.settings = MagicMock()
15+
config.settings.ENV = "LIVE"
16+
config.settings.from_env.return_value = env_settings
17+
18+
19+
def test_reconcile_global_reset_on_base_path_change(tmp_path):
20+
config.config_dir = str(tmp_path)
21+
db_name = "LIVE_resources.db"
22+
23+
download_folder_1 = str(tmp_path / "downloads1")
24+
_set_env_settings(download_folder_1, "", {})
25+
store.initialize_db(db_name, "LIVE")
26+
27+
with store.get_db_connection(db_name) as conn:
28+
cursor = conn.cursor()
29+
cursor.execute(
30+
"INSERT INTO downloads (resource_id, parent_id, version_local) VALUES (?, ?, ?)",
31+
(153, 0, 5),
32+
)
33+
34+
download_folder_2 = str(tmp_path / "downloads2")
35+
_set_env_settings(download_folder_2, "", {})
36+
store.initialize_db(db_name, "LIVE")
37+
38+
with store.get_db_connection(db_name) as conn:
39+
cursor = conn.cursor()
40+
cursor.execute(
41+
"SELECT version_local FROM downloads WHERE resource_id = ? AND parent_id = 0",
42+
(153,),
43+
)
44+
row = cursor.fetchone()
45+
assert row[0] == 0
46+
47+
48+
def test_reconcile_targeted_reset_on_special_destination_change(tmp_path):
49+
config.config_dir = str(tmp_path)
50+
db_name = "LIVE_resources.db"
51+
52+
download_folder = str(tmp_path / "downloads")
53+
eqpath_1 = str(tmp_path / "eq1")
54+
eqpath_2 = str(tmp_path / "eq2")
55+
56+
special_resources_1 = {
57+
"153": {"default_path": os.path.join(eqpath_1, "maps"), "custom_path": ""},
58+
"1865": {"default_path": "MySEQ", "custom_path": ""},
59+
}
60+
_set_env_settings(download_folder, eqpath_1, special_resources_1)
61+
store.initialize_db(db_name, "LIVE")
62+
63+
with store.get_db_connection(db_name) as conn:
64+
cursor = conn.cursor()
65+
cursor.execute(
66+
"INSERT INTO downloads (resource_id, parent_id, version_local) VALUES (?, ?, ?)",
67+
(153, 0, 9),
68+
)
69+
cursor.execute(
70+
"INSERT INTO downloads (resource_id, parent_id, version_local) VALUES (?, ?, ?)",
71+
(999, 153, 7),
72+
)
73+
cursor.execute(
74+
"INSERT INTO downloads (resource_id, parent_id, version_local) VALUES (?, ?, ?)",
75+
(1865, 0, 8),
76+
)
77+
cursor.execute(
78+
"INSERT INTO downloads (resource_id, parent_id, version_local) VALUES (?, ?, ?)",
79+
(153, 151, 6),
80+
)
81+
82+
special_resources_2 = {
83+
"153": {"default_path": os.path.join(eqpath_2, "maps"), "custom_path": ""},
84+
"1865": {"default_path": "MySEQ", "custom_path": ""},
85+
}
86+
_set_env_settings(download_folder, eqpath_2, special_resources_2)
87+
store.initialize_db(db_name, "LIVE")
88+
89+
with store.get_db_connection(db_name) as conn:
90+
cursor = conn.cursor()
91+
cursor.execute(
92+
"SELECT version_local FROM downloads WHERE resource_id = ? AND parent_id = 0",
93+
(153,),
94+
)
95+
row_153_root = cursor.fetchone()
96+
cursor.execute(
97+
"SELECT version_local FROM downloads WHERE resource_id = ? AND parent_id = ?",
98+
(999, 153),
99+
)
100+
row_153_dep = cursor.fetchone()
101+
cursor.execute(
102+
"SELECT version_local FROM downloads WHERE resource_id = ? AND parent_id = 0",
103+
(1865,),
104+
)
105+
row_1865_root = cursor.fetchone()
106+
cursor.execute(
107+
"SELECT version_local FROM downloads WHERE resource_id = ? AND parent_id = ?",
108+
(153, 151),
109+
)
110+
row_153_as_dep = cursor.fetchone()
111+
112+
assert row_153_root[0] == 0
113+
assert row_153_dep[0] == 0
114+
assert row_1865_root[0] == 8
115+
assert row_153_as_dep[0] == 6
116+
117+
118+
def test_config_command_reconciles_signature_for_server(monkeypatch):
119+
called = {}
120+
121+
def fake_init():
122+
return None
123+
124+
def fake_update(setting_path, value, env=None):
125+
return None
126+
127+
def fake_reconcile(db_name, settings_env):
128+
called["db_name"] = db_name
129+
called["settings_env"] = settings_env
130+
return None
131+
132+
monkeypatch.setattr(config, "initialize_config", fake_init)
133+
monkeypatch.setattr(config, "update_setting", fake_update)
134+
monkeypatch.setattr(store, "reconcile_install_signature", fake_reconcile)
135+
136+
main.config_command("EQPATH", "C:\\Games\\EverQuest", server=main.Env.TEST)
137+
138+
assert called == {"db_name": "TEST_resources.db", "settings_env": "TEST"}

0 commit comments

Comments
 (0)