Repository navigation
Expand file tree
/
Copy pathconfig.py
More file actions
180 lines (146 loc) · 6.58 KB
/
Copy pathconfig.py
File metadata and controls
180 lines (146 loc) · 6.58 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
"""Portable configuration: locate, load, and save ``poltergeist.json``.
Config always lives next to the running executable (or, in dev, next to
``main.py``) so the whole app is copy-paste portable.
Distribution bootstrap
----------------------
A distributor can drop a **``poltergeist-defaults.json``** next to the
executable before shipping (e.g. inside the onboarding ZIP). On first
launch - i.e. when no ``poltergeist.json`` exists yet - the defaults
file is loaded as the starting config and persisted on the next save.
This lets new hires unpack the ZIP, double-click the exe, and be
dropped straight into a working setup with the team share path, the
database paths, the context patterns, and an example snippet already
filled in.
The defaults file is ignored on subsequent launches - once the user
has a ``poltergeist.json`` it is the only source of truth. Deleting
``poltergeist.json`` and restarting the app therefore acts as a
"factory reset" that re-bootstraps from the shipped defaults.
"""
from __future__ import annotations
import json
import os
import sys
from pathlib import Path
from typing import Any
CONFIG_FILENAME = "poltergeist.json"
DEFAULTS_FILENAME = "poltergeist-defaults.json"
def app_dir() -> Path:
"""Directory the running app lives in (portable anchor)."""
if getattr(sys, "frozen", False):
return Path(sys.executable).resolve().parent
return Path(__file__).resolve().parent
def resource_path(relative: str) -> Path:
"""Locate a bundled resource in dev and inside a PyInstaller one-file build."""
base = getattr(sys, "_MEIPASS", None)
if base:
candidate = Path(base) / relative
if candidate.exists():
return candidate
return app_dir() / relative
def config_path() -> Path:
return app_dir() / CONFIG_FILENAME
def defaults_path() -> Path:
"""Location of the distribution bootstrap template, if any. The
file is optional - absence is the common case for dev checkouts
and for users who have already run the app once."""
return app_dir() / DEFAULTS_FILENAME
def default_config() -> dict[str, Any]:
return {
"version": 2,
"settings": {
"hotkey": "ctrl+alt+space",
"default_injection": "clipboard",
"default_date_format": "%d/%m/%Y",
"start_with_windows": False,
"theme": "auto",
"deepl_api_key": "",
"language": "",
"team_share_path": "",
"team_shortcuts": {},
"context_patterns": [],
},
"tree_personal": [],
"tree_team": [],
}
def _read_json_file(path: Path) -> Any:
"""Read ``path`` as JSON, returning ``None`` if the file is absent,
unreadable, or not valid JSON. Swallowing these errors is the
right move here - a bad config must never take the whole app down
at startup; the caller gets to decide on a fallback."""
if not path.exists():
return None
try:
with path.open("r", encoding="utf-8") as fh:
return json.load(fh)
except (OSError, json.JSONDecodeError):
return None
def _strip_pseudo_comments(data: dict[str, Any]) -> dict[str, Any]:
"""Return a shallow copy of ``data`` with any pseudo-comment keys
removed. JSON has no native comment syntax, so the defaults
template uses leading-underscore keys (``_README``, ``_comment_*``,
``_note``, ...) as stand-ins. They exist purely to make the
template self-documenting for distributors and must not leak into
the runtime config or they would round-trip into the user's
``poltergeist.json`` and pile up over time."""
return {k: v for k, v in data.items() if not k.startswith("_")}
def _merge_into_default(data: Any) -> dict[str, Any]:
"""Project arbitrary (possibly old-schema) JSON onto the current
v2 config shape, filling gaps from :func:`default_config`. Used
both for loading ``poltergeist.json`` and for bootstrapping from
``poltergeist-defaults.json`` so the two paths share one set of
migration / validation rules."""
merged = default_config()
if not isinstance(data, dict):
return merged
settings = data.get("settings")
if isinstance(settings, dict):
merged["settings"].update(_strip_pseudo_comments(settings))
if isinstance(data.get("tree_personal"), list):
merged["tree_personal"] = data["tree_personal"]
elif isinstance(data.get("tree"), list):
merged["tree_personal"] = data["tree"]
if isinstance(data.get("tree_team"), list):
merged["tree_team"] = data["tree_team"]
if "version" in data:
merged["version"] = data["version"]
return merged
def load() -> dict[str, Any]:
"""Load the on-disk config, migrating older versions on the fly
and bootstrapping from a shipped defaults file when appropriate.
Resolution order:
1. If ``poltergeist.json`` exists next to the exe, it wins
(normal every-day path, including after the bootstrap).
2. Otherwise, if ``poltergeist-defaults.json`` is present, load
it as the starting config. The file remains on disk; a
subsequent :func:`save` persists the (possibly already
edited) state to ``poltergeist.json``.
3. Otherwise, fall back to :func:`default_config` - an empty
but valid v2 layout.
Version history:
* v1: single ``tree`` list, no Team/Personal distinction.
* v2: ``tree`` becomes ``tree_personal``; ``tree_team`` is added
alongside it; ``settings`` gains ``team_share_path``,
``team_shortcuts``, ``context_patterns``.
v1 configs are migrated transparently - the old ``tree`` is moved
to ``tree_personal`` and ``tree_team`` starts empty. We never
delete the user's data here; :func:`save` re-emits it in the v2
shape on the next write.
"""
data = _read_json_file(config_path())
if data is None:
data = _read_json_file(defaults_path())
return _merge_into_default(data)
def bootstrapped_from_defaults() -> bool:
"""True when the current process is running before the first
``poltergeist.json`` has been written but a defaults file is
sitting next to the exe. Useful for UI hints (e.g. a one-shot
toast telling the user 'settings prefilled from defaults -
review Options > Team before publishing anything')."""
return not config_path().exists() and defaults_path().exists()
def save(data: dict[str, Any]) -> None:
path = config_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with tmp.open("w", encoding="utf-8") as fh:
json.dump(data, fh, indent=2, ensure_ascii=False)
os.replace(tmp, path)