github-heatmap 1.3.8__tar.gz → 1.4.0__tar.gz

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.
Files changed (70) hide show
  1. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/PKG-INFO +1 -1
  2. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/cli.py +4 -0
  3. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/drawer.py +46 -4
  4. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/base_loader.py +6 -0
  5. github_heatmap-1.4.0/github_heatmap/loader/notion_loader.py +314 -0
  6. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/poster.py +2 -0
  7. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/PKG-INFO +1 -1
  8. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/SOURCES.txt +1 -0
  9. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/setup.py +1 -1
  10. github_heatmap-1.4.0/tests/test_notion_loader.py +135 -0
  11. github_heatmap-1.4.0/tests/test_poster_utils.py +46 -0
  12. github_heatmap-1.3.8/github_heatmap/loader/notion_loader.py +0 -149
  13. github_heatmap-1.3.8/tests/test_poster_utils.py +0 -19
  14. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/LICENSE +0 -0
  15. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/README.md +0 -0
  16. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/__init__.py +0 -0
  17. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/__main__.py +0 -0
  18. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/circluar_drawer.py +0 -0
  19. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/config.py +0 -0
  20. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/err.py +0 -0
  21. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/html_parser/__init__.py +0 -0
  22. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/html_parser/github_parser.py +0 -0
  23. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/html_parser/gitlab_parser.py +0 -0
  24. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/html_parser/jike_parse.py +0 -0
  25. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/html_parser/kindle_parser.py +0 -0
  26. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/__init__.py +0 -0
  27. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/apple_health_loader.py +0 -0
  28. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/bbdc_loader.py +0 -0
  29. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/bilibili_loader.py +0 -0
  30. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/chatgpt_loader.py +0 -0
  31. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/cichang_loader.py +0 -0
  32. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/config.py +0 -0
  33. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/covid_loader.py +0 -0
  34. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/dota2_loader.py +0 -0
  35. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/duolingo_loader.py +0 -0
  36. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/forest_loader.py +0 -0
  37. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/from_github_issue_loader.py +0 -0
  38. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/garmin_loader.py +0 -0
  39. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/github_loader.py +0 -0
  40. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/gitlab_loader.py +0 -0
  41. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/gpx_loader.py +0 -0
  42. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/jike_loader.py +0 -0
  43. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/json_loader.py +0 -0
  44. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/kindle_loader.py +0 -0
  45. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/leetcode_loader.py +0 -0
  46. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/multiple_loader.py +0 -0
  47. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/neodb_loader.py +0 -0
  48. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/nrc_loader.py +0 -0
  49. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/ns_loader.py +0 -0
  50. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/openlanguage_loader.py +0 -0
  51. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/shanbay_loader.py +0 -0
  52. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/strava_loader.py +0 -0
  53. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/summary_loader.py +0 -0
  54. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/todoist_loader.py +0 -0
  55. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/wakatime_loader.py +0 -0
  56. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/weread_loader.py +0 -0
  57. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/loader/youtube_loader.py +0 -0
  58. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/skyline/__init__.py +0 -0
  59. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/skyline/config.py +0 -0
  60. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/skyline/font/__init__.py +0 -0
  61. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/skyline/skyline.py +0 -0
  62. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/structures.py +0 -0
  63. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap/utils.py +0 -0
  64. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/dependency_links.txt +0 -0
  65. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/entry_points.txt +0 -0
  66. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/requires.txt +0 -0
  67. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/github_heatmap.egg-info/top_level.txt +0 -0
  68. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/pyproject.toml +0 -0
  69. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/setup.cfg +0 -0
  70. {github_heatmap-1.3.8 → github_heatmap-1.4.0}/tests/__init__.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github_heatmap
3
- Version: 1.3.8
3
+ Version: 1.4.0
4
4
  Summary: Make everything a GitHub svg poster and Skyline!
5
5
  Home-page: https://github.com/malinkang/GitHubPoster
6
6
  Author: malinkang
@@ -61,6 +61,8 @@ def run():
61
61
  "dom": args.dom_color,
62
62
  }
63
63
 
64
+ p.tooltip_template = args.tooltip_template or None
65
+
64
66
  # if special color (Stand with Ukraine) change the color
65
67
  if args.stand_with_ukraine:
66
68
  p.colors["track"] = "#025DB8"
@@ -108,6 +110,7 @@ def run():
108
110
  p.units = args.unit
109
111
  else:
110
112
  p.units = args.loader.unit
113
+ p.tooltip_by_date = getattr(loader, "tooltip_by_date_dict", {}) or {}
111
114
  p.set_tracks(tracks, years, type_list)
112
115
  else:
113
116
  if args.unit:
@@ -115,6 +118,7 @@ def run():
115
118
  else:
116
119
  p.units = args.loader.unit
117
120
  p.set_tracks({}, [to_year], type_list)
121
+ p.tooltip_by_date = {}
118
122
 
119
123
  # set title
120
124
  # we don't know issue content so use name
@@ -35,6 +35,42 @@ class Drawer:
35
35
  zip(self.poster.type_list, COLOR_TUPLE[: len(self.poster.type_list)])
36
36
  )
37
37
 
38
+ def _format_tooltip(self, date_title, value=None, type_name=None):
39
+ custom_map = getattr(self.poster, "tooltip_by_date", {}) or {}
40
+ custom = None
41
+ if custom_map:
42
+ entry = custom_map.get(date_title)
43
+ if isinstance(entry, dict):
44
+ if type_name and type_name in entry:
45
+ custom = entry.get(type_name)
46
+ elif "__default__" in entry:
47
+ custom = entry.get("__default__")
48
+ else:
49
+ custom = entry
50
+ if isinstance(custom, list):
51
+ custom = "\n".join([str(item) for item in custom if item is not None])
52
+ if custom is not None and custom != "":
53
+ return str(custom)
54
+ template = self.poster.tooltip_template
55
+ if template and value is not None:
56
+ context = {
57
+ "date": date_title,
58
+ "value": value if value is not None else "",
59
+ "unit": self.poster.units if value is not None else "",
60
+ "type": type_name or "",
61
+ }
62
+ try:
63
+ tooltip = template.format(**context)
64
+ except KeyError:
65
+ tooltip = template
66
+ if tooltip:
67
+ return tooltip
68
+ if value is not None:
69
+ if type_name:
70
+ return f"{date_title} {value} for {type_name}"
71
+ return f"{date_title} {value} {self.poster.units}"
72
+ return date_title
73
+
38
74
  def make_color(self, length_range, length):
39
75
  sp2 = self.poster.special_number.get("special_number2")
40
76
  sp1 = self.poster.special_number.get("special_number1")
@@ -90,7 +126,9 @@ class Drawer:
90
126
  color = self.poster.colors.get("special2") or self.poster.colors.get(
91
127
  "special"
92
128
  )
93
- date_title = f"{date_title} {day_tracks} {self.poster.units}"
129
+ tooltip = self._format_tooltip(date_title, day_tracks)
130
+ else:
131
+ tooltip = self._format_tooltip(date_title)
94
132
  rect = dr.rect(
95
133
  (rect_x, rect_y),
96
134
  DOM_BOX_TUPLE,
@@ -100,7 +138,7 @@ class Drawer:
100
138
  )
101
139
  if with_animation:
102
140
  rect = self.__add_animation(rect, key_times, animate_index)
103
- rect.set_desc(title=date_title)
141
+ rect.set_desc(title=tooltip)
104
142
  yield rect
105
143
 
106
144
  def _gen_day_boxes(
@@ -126,6 +164,7 @@ class Drawer:
126
164
  types_len = len(day_tracks)
127
165
  dom_tuple = DOM_BOX_DICT.get(types_len).get("dom")
128
166
  index = 0
167
+ base_title = date_title
129
168
  for _type in self.poster.type_list:
130
169
  num = day_tracks.get(_type, 0)
131
170
  length_range = self.poster.length_range_by_date_dict.get(_type, 1)
@@ -140,10 +179,10 @@ class Drawer:
140
179
  rx=DOM_BOX_RADIUS,
141
180
  ry=DOM_BOX_RADIUS,
142
181
  )
143
- date_title = f"{date_title} {num} for {_type}"
182
+ tooltip = self._format_tooltip(base_title, num, _type)
144
183
  if with_animation:
145
184
  rect = self.__add_animation(rect, key_times, animate_index)
146
- rect.set_desc(title=date_title)
185
+ rect.set_desc(title=tooltip)
147
186
  yield rect
148
187
  rect_y += dom_tuple[index][1]
149
188
  index += 1
@@ -269,6 +308,9 @@ class Drawer:
269
308
  "special_number1": loader.special_number1,
270
309
  "special_number2": loader.special_number2,
271
310
  }
311
+ self.poster.tooltip_by_date = (
312
+ getattr(loader, "tooltip_by_date_dict", {}) or {}
313
+ )
272
314
  self.poster.colors["track"] = loader.track_color or "#4DD2FF"
273
315
  self.poster.units = loader.unit
274
316
  self.poster.compute_track_statistics([loader._type])
@@ -218,6 +218,12 @@ class BaseLoader(ABC):
218
218
  default="",
219
219
  help="unit",
220
220
  )
221
+ group.add_argument(
222
+ "--tooltip-template",
223
+ dest="tooltip_template",
224
+ default="",
225
+ help="format string for tooltip, supports {date}, {value}, {unit}, {type}",
226
+ )
221
227
  # is_cn here
222
228
  group.add_argument(
223
229
  "--cn",
@@ -0,0 +1,314 @@
1
+ from datetime import datetime, timedelta
2
+ import time
3
+ from collections import defaultdict
4
+
5
+ import pendulum
6
+ import requests
7
+ import json
8
+
9
+ from github_heatmap.loader.base_loader import BaseLoader
10
+ from github_heatmap.loader.config import NOTION_API_URL, NOTION_API_VERSION
11
+
12
+
13
+ class NotionLoader(BaseLoader):
14
+ track_color = "#40C463"
15
+ unit = "times"
16
+
17
+ def __init__(self, from_year, to_year, _type, **kwargs):
18
+ super().__init__(from_year, to_year, _type)
19
+ self.number_by_date_dict = self.generate_date_dict(from_year, to_year)
20
+ self.notion_token = kwargs.get("notion_token", "").strip()
21
+ self.database_id = kwargs.get("database_id", "").strip()
22
+ self.date_prop_name = kwargs.get("date_prop_name", "")
23
+ self.value_prop_name = kwargs.get("value_prop_name", "")
24
+ self.database_filter = kwargs.get("database_filter", "")
25
+ self.tooltip_prop_name = kwargs.get("tooltip_prop_name", "").strip()
26
+ self.tooltip_by_date_dict = defaultdict(list)
27
+
28
+ @classmethod
29
+ def add_loader_arguments(cls, parser, optional):
30
+ parser.add_argument(
31
+ "--notion_token",
32
+ dest="notion_token",
33
+ type=str,
34
+ help="The Notion internal integration token.",
35
+ )
36
+ parser.add_argument(
37
+ "--database_id",
38
+ dest="database_id",
39
+ type=str,
40
+ help="The Notion database id.",
41
+ )
42
+ parser.add_argument(
43
+ "--date_prop_name",
44
+ dest="date_prop_name",
45
+ type=str,
46
+ default="Datetime",
47
+ required=optional,
48
+ help="The database property name which stored the datetime.",
49
+ )
50
+ parser.add_argument(
51
+ "--value_prop_name",
52
+ dest="value_prop_name",
53
+ type=str,
54
+ default="Datetime",
55
+ required=optional,
56
+ help="The database property name which stored the datetime.",
57
+ )
58
+ parser.add_argument(
59
+ "--database_filter",
60
+ dest="database_filter",
61
+ type=str,
62
+ default="",
63
+ required=False,
64
+ help="The database property name which stored the datetime.",
65
+ )
66
+ parser.add_argument(
67
+ "--tooltip_prop_name",
68
+ dest="tooltip_prop_name",
69
+ type=str,
70
+ default="",
71
+ required=False,
72
+ help="The Notion property name used for tooltip text.",
73
+ )
74
+
75
+ def get_api_data(self, start_cursor="", page_size=100, data_list=None):
76
+ if data_list is None:
77
+ data_list = []
78
+ payload = {
79
+ "page_size": page_size,
80
+ "filter": {
81
+ "and": [
82
+ {
83
+ "property": self.date_prop_name,
84
+ "date": {"on_or_after": f"{self.from_year}-01-01"},
85
+ },
86
+ {
87
+ "property": self.date_prop_name,
88
+ "date": {"on_or_before": f"{self.to_year}-12-31"},
89
+ },
90
+ ]
91
+ },
92
+ }
93
+ if self.database_filter:
94
+ payload["filter"]["and"].append(json.loads(self.database_filter))
95
+ if start_cursor:
96
+ payload.update({"start_cursor": start_cursor})
97
+
98
+ headers = {
99
+ "Accept": "application/json",
100
+ "Notion-Version": NOTION_API_VERSION,
101
+ "Content-Type": "application/json",
102
+ "Authorization": "Bearer " + self.notion_token,
103
+ }
104
+
105
+ try:
106
+ resp = requests.post(
107
+ NOTION_API_URL.format(database_id=self.database_id),
108
+ json=payload,
109
+ headers=headers,
110
+ )
111
+ except requests.RequestException:
112
+ return data_list
113
+
114
+ if not resp.ok:
115
+ # Treat non-OK responses as an empty result set so we can still draw
116
+ # a heatmap even when the Notion API yields no rows for the period.
117
+ return data_list
118
+ try:
119
+ data = resp.json()
120
+ except ValueError:
121
+ return data_list
122
+ results = data["results"]
123
+ next_cursor = data["next_cursor"]
124
+ data_list.extend(results)
125
+ if not data["has_more"]:
126
+ return data_list
127
+ # Avoid request limits
128
+ # The rate limit for incoming requests is an average
129
+ # of 3 requests per second.
130
+ # See https://developers.notion.com/reference/request-limits
131
+ time.sleep(0.3)
132
+ return self.get_api_data(
133
+ start_cursor=next_cursor, page_size=page_size, data_list=data_list
134
+ )
135
+
136
+ def generate_date_dict(self, start_year, end_year):
137
+ start_date = datetime(start_year, 1, 1)
138
+ end_date = datetime(end_year, 12, 31)
139
+
140
+ # 使用字典推导式生成日期字典
141
+ return {
142
+ (start_date + timedelta(days=i)).strftime("%Y-%m-%d"): 0
143
+ for i in range((end_date - start_date).days + 1)
144
+ }
145
+
146
+ def make_track_dict(self):
147
+ data_list = self.get_api_data()
148
+ for result in data_list:
149
+ date = result["properties"][self.date_prop_name]["date"]
150
+ value = result["properties"][self.value_prop_name]
151
+ tooltip_text = ""
152
+ if self.tooltip_prop_name:
153
+ tooltip_prop = result["properties"].get(self.tooltip_prop_name)
154
+ tooltip_text = self._extract_property_text(tooltip_prop)
155
+ if date and value:
156
+ dt = date.get("start")
157
+ type = value.get("type")
158
+ if type == "formula" and value.get(type).get("type") == "number":
159
+ value = float(value.get(type).get("number"))
160
+ elif type == "rollup" and value.get(type).get("type") == "number":
161
+ value = float(value.get(type).get("number"))
162
+ else:
163
+ value = value.get(type)
164
+ date_str = pendulum.parse(dt).to_date_string()
165
+ self.number_by_date_dict[date_str] = (
166
+ self.number_by_date_dict.get(date_str, 0) + value
167
+ )
168
+ if tooltip_text:
169
+ self.tooltip_by_date_dict[date_str].append(tooltip_text.strip())
170
+ for _, v in self.number_by_date_dict.items():
171
+ self.number_list.append(v)
172
+ for key, texts in list(self.tooltip_by_date_dict.items()):
173
+ joined = "\n".join(filter(None, [t.strip() for t in texts if t]))
174
+ if joined:
175
+ self.tooltip_by_date_dict[key] = joined
176
+ else:
177
+ self.tooltip_by_date_dict.pop(key, None)
178
+
179
+ def get_all_track_data(self):
180
+ self.make_track_dict()
181
+ self.make_special_number()
182
+ return self.number_by_date_dict, self.year_list
183
+
184
+ @staticmethod
185
+ def _format_date(date_dict):
186
+ if not date_dict:
187
+ return ""
188
+ start = date_dict.get("start")
189
+ end = date_dict.get("end")
190
+ if start and end and start != end:
191
+ return f"{start} - {end}"
192
+ return start or end or ""
193
+
194
+ @staticmethod
195
+ def _join_plain_text(items):
196
+ if not items:
197
+ return ""
198
+ return "".join([item.get("plain_text", "") for item in items]).strip()
199
+
200
+ @classmethod
201
+ def _extract_rollup_item(cls, item):
202
+ if not item:
203
+ return ""
204
+ item_type = item.get("type")
205
+ if item_type in ("title", "rich_text"):
206
+ return cls._join_plain_text(item.get(item_type))
207
+ if item_type == "people":
208
+ return ", ".join(
209
+ [p.get("name") or p.get("id", "") for p in item.get("people", [])]
210
+ ).strip()
211
+ if item_type == "relation":
212
+ return ", ".join([r.get("id", "") for r in item.get("relation", [])]).strip()
213
+ if item_type == "date":
214
+ return cls._format_date(item.get("date", {}))
215
+ if item_type in ("url", "email", "phone_number"):
216
+ return item.get(item_type) or ""
217
+ if item_type == "number":
218
+ number_value = item.get("number")
219
+ return "" if number_value is None else str(number_value)
220
+ if item_type == "checkbox":
221
+ value = item.get("checkbox")
222
+ if value is None:
223
+ return ""
224
+ return "Yes" if value else "No"
225
+ if item_type == "formula":
226
+ return cls._extract_property_text(
227
+ {"type": "formula", "formula": item.get("formula", {})}
228
+ )
229
+ if item_type == "rollup":
230
+ return cls._extract_property_text(
231
+ {"type": "rollup", "rollup": item.get("rollup", {})}
232
+ )
233
+ return ""
234
+
235
+ @classmethod
236
+ def _extract_property_text(cls, prop):
237
+ if not prop:
238
+ return ""
239
+ prop_type = prop.get("type")
240
+ if not prop_type:
241
+ return ""
242
+ if prop_type in ("title", "rich_text"):
243
+ return cls._join_plain_text(prop.get(prop_type))
244
+ if prop_type == "multi_select":
245
+ return ", ".join(
246
+ [item.get("name", "") for item in prop.get("multi_select", []) if item]
247
+ ).strip()
248
+ if prop_type in ("select", "status"):
249
+ option = prop.get(prop_type) or {}
250
+ return option.get("name", "") if option else ""
251
+ if prop_type in ("url", "email", "phone_number"):
252
+ return prop.get(prop_type) or ""
253
+ if prop_type == "checkbox":
254
+ value = prop.get("checkbox")
255
+ if value is None:
256
+ return ""
257
+ return "Yes" if value else "No"
258
+ if prop_type == "number":
259
+ number_value = prop.get("number")
260
+ return "" if number_value is None else str(number_value)
261
+ if prop_type == "people":
262
+ return ", ".join(
263
+ [p.get("name") or p.get("id", "") for p in prop.get("people", [])]
264
+ ).strip()
265
+ if prop_type in ("created_time", "last_edited_time"):
266
+ return prop.get(prop_type) or ""
267
+ if prop_type in ("created_by", "last_edited_by"):
268
+ person = prop.get(prop_type)
269
+ if isinstance(person, dict):
270
+ return person.get("name") or person.get("id", "") or ""
271
+ return ""
272
+ if prop_type == "date":
273
+ return cls._format_date(prop.get("date", {}))
274
+ if prop_type == "relation":
275
+ return ", ".join([r.get("id", "") for r in prop.get("relation", [])]).strip()
276
+ if prop_type == "files":
277
+ texts = []
278
+ for file_obj in prop.get("files", []):
279
+ name = file_obj.get("name")
280
+ if name:
281
+ texts.append(name)
282
+ continue
283
+ file_url = file_obj.get("file", {}).get("url")
284
+ external_url = file_obj.get("external", {}).get("url")
285
+ texts.append(file_url or external_url or "")
286
+ return ", ".join([t for t in texts if t]).strip()
287
+ if prop_type == "formula":
288
+ formula = prop.get("formula", {})
289
+ formula_type = formula.get("type")
290
+ if formula_type in ("string", "url", "email", "phone_number"):
291
+ return formula.get(formula_type) or ""
292
+ if formula_type == "boolean":
293
+ value = formula.get("boolean")
294
+ if value is None:
295
+ return ""
296
+ return "Yes" if value else "No"
297
+ if formula_type == "number":
298
+ number_value = formula.get("number")
299
+ return "" if number_value is None else str(number_value)
300
+ if formula_type == "date":
301
+ return cls._format_date(formula.get("date", {}))
302
+ if prop_type == "rollup":
303
+ rollup = prop.get("rollup", {})
304
+ rollup_type = rollup.get("type")
305
+ if rollup_type == "number":
306
+ number_value = rollup.get("number")
307
+ return "" if number_value is None else str(number_value)
308
+ if rollup_type == "date":
309
+ return cls._format_date(rollup.get("date", {}))
310
+ if rollup_type == "array":
311
+ texts = [cls._extract_rollup_item(item) for item in rollup.get("array", [])]
312
+ texts = [text for text in texts if text]
313
+ return ", ".join(texts)
314
+ return ""
@@ -39,6 +39,8 @@ class Poster:
39
39
 
40
40
  # for year summary
41
41
  self.is_summary = False
42
+ self.tooltip_template = None
43
+ self.tooltip_by_date = {}
42
44
 
43
45
  def set_tracks(self, tracks, years, type_list):
44
46
  self.type_list.extend(type_list)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: github_heatmap
3
- Version: 1.3.8
3
+ Version: 1.4.0
4
4
  Summary: Make everything a GitHub svg poster and Skyline!
5
5
  Home-page: https://github.com/malinkang/GitHubPoster
6
6
  Author: malinkang
@@ -62,4 +62,5 @@ github_heatmap/skyline/config.py
62
62
  github_heatmap/skyline/skyline.py
63
63
  github_heatmap/skyline/font/__init__.py
64
64
  tests/__init__.py
65
+ tests/test_notion_loader.py
65
66
  tests/test_poster_utils.py
@@ -6,7 +6,7 @@ setup(
6
6
  author_email="linkang.ma@gmail.com",
7
7
  url="https://github.com/malinkang/GitHubPoster",
8
8
  license="MIT",
9
- version="1.3.8",
9
+ version="1.4.0",
10
10
  description="Make everything a GitHub svg poster and Skyline!",
11
11
  packages=find_packages(),
12
12
  include_package_data=True,
@@ -0,0 +1,135 @@
1
+ import requests
2
+
3
+ from github_heatmap.loader import notion_loader
4
+ from github_heatmap.loader.notion_loader import NotionLoader
5
+
6
+
7
+ def build_loader():
8
+ return NotionLoader(
9
+ 2024,
10
+ 2024,
11
+ "notion",
12
+ notion_token="token",
13
+ database_id="db",
14
+ date_prop_name="Date",
15
+ value_prop_name="Value",
16
+ tooltip_prop_name="Tooltip",
17
+ )
18
+
19
+
20
+ def test_notion_loader_make_track_dict_with_tooltips():
21
+ loader = build_loader()
22
+
23
+ loader.get_api_data = lambda: [
24
+ {
25
+ "properties": {
26
+ "Date": {"date": {"start": "2024-01-01"}},
27
+ "Value": {"type": "number", "number": 5},
28
+ "Tooltip": {
29
+ "type": "rich_text",
30
+ "rich_text": [{"plain_text": "Study"}],
31
+ },
32
+ }
33
+ },
34
+ {
35
+ "properties": {
36
+ "Date": {"date": {"start": "2024-01-01"}},
37
+ "Value": {"type": "number", "number": 2},
38
+ "Tooltip": {
39
+ "type": "multi_select",
40
+ "multi_select": [{"name": "Read"}, {"name": "Write"}],
41
+ },
42
+ }
43
+ },
44
+ ]
45
+
46
+ loader.make_track_dict()
47
+
48
+ assert loader.number_by_date_dict["2024-01-01"] == 7
49
+ assert (
50
+ loader.tooltip_by_date_dict["2024-01-01"]
51
+ == "Study\nRead, Write"
52
+ )
53
+
54
+
55
+ def test_notion_loader_extract_property_text_variants():
56
+ rich_text = {
57
+ "type": "rich_text",
58
+ "rich_text": [{"plain_text": "Hello"}, {"plain_text": " World"}],
59
+ }
60
+ assert NotionLoader._extract_property_text(rich_text) == "Hello World"
61
+
62
+ select = {
63
+ "type": "select",
64
+ "select": {"name": "Done"},
65
+ }
66
+ assert NotionLoader._extract_property_text(select) == "Done"
67
+
68
+ multi_select = {
69
+ "type": "multi_select",
70
+ "multi_select": [{"name": "Tag1"}, {"name": "Tag2"}],
71
+ }
72
+ assert NotionLoader._extract_property_text(multi_select) == "Tag1, Tag2"
73
+
74
+ formula_number = {
75
+ "type": "formula",
76
+ "formula": {"type": "number", "number": 42},
77
+ }
78
+ assert NotionLoader._extract_property_text(formula_number) == "42"
79
+
80
+ rollup_array = {
81
+ "type": "rollup",
82
+ "rollup": {
83
+ "type": "array",
84
+ "array": [
85
+ {
86
+ "type": "rich_text",
87
+ "rich_text": [{"plain_text": "Note"}],
88
+ },
89
+ {
90
+ "type": "people",
91
+ "people": [{"name": "Alice"}, {"name": "Bob"}],
92
+ },
93
+ ],
94
+ },
95
+ }
96
+ assert NotionLoader._extract_property_text(rollup_array) == "Note, Alice, Bob"
97
+
98
+
99
+ def test_notion_loader_handles_http_error(monkeypatch):
100
+ loader = build_loader()
101
+
102
+ class DummyResp:
103
+ ok = False
104
+ status_code = 404
105
+
106
+ @staticmethod
107
+ def json():
108
+ return {"object": "error"}
109
+
110
+ monkeypatch.setattr(
111
+ notion_loader.requests, "post", lambda *args, **kwargs: DummyResp()
112
+ )
113
+
114
+ tracks, years = loader.get_all_track_data()
115
+
116
+ assert years == [2024]
117
+ assert tracks
118
+ assert all(value == 0 for value in tracks.values())
119
+ assert loader.number_list
120
+
121
+
122
+ def test_notion_loader_handles_request_exception(monkeypatch):
123
+ loader = build_loader()
124
+
125
+ def fake_post(*args, **kwargs):
126
+ raise requests.RequestException("boom")
127
+
128
+ monkeypatch.setattr(notion_loader.requests, "post", fake_post)
129
+
130
+ tracks, years = loader.get_all_track_data()
131
+
132
+ assert years == [2024]
133
+ assert tracks
134
+ assert all(value == 0 for value in tracks.values())
135
+ assert loader.number_list
@@ -0,0 +1,46 @@
1
+ from github_heatmap.drawer import Drawer
2
+ from github_heatmap.poster import Poster
3
+ from github_heatmap.utils import interpolate_color, make_key_times, parse_years
4
+
5
+
6
+ def test_interpolate_color():
7
+ assert interpolate_color("#000000", "#ffffff", 0) == "#000000"
8
+ assert interpolate_color("#000000", "#ffffff", 1) == "#ffffff"
9
+ assert interpolate_color("#000000", "#ffffff", 0.5) == "#7f7f7f"
10
+ assert interpolate_color("#000000", "#ffffff", -100) == "#000000"
11
+ assert interpolate_color("#000000", "#ffffff", 12345) == "#ffffff"
12
+
13
+
14
+ def test_parse_years():
15
+ assert parse_years("2012") == (2012, 2012)
16
+ assert parse_years("2015-2021") == (2015, 2021)
17
+ assert parse_years("2021-2015") == (2015, 2021)
18
+
19
+
20
+ def test_make_key_times():
21
+ assert make_key_times(5) == ["0", "0.2", "0.4", "0.6", "0.8", "1"]
22
+
23
+
24
+ def test_drawer_tooltip_formatting():
25
+ poster = Poster()
26
+ poster.units = "XP"
27
+ drawer = Drawer(poster)
28
+
29
+ assert drawer._format_tooltip("2024-01-01", 10) == "2024-01-01 10 XP"
30
+ assert drawer._format_tooltip("2024-01-01") == "2024-01-01"
31
+
32
+ poster.tooltip_template = "{date}: {value}{unit}"
33
+ assert drawer._format_tooltip("2024-01-01", 5) == "2024-01-01: 5XP"
34
+ assert drawer._format_tooltip("2024-01-01") == "2024-01-01"
35
+
36
+ poster.tooltip_template = "{date} {value} for {type}"
37
+ assert (
38
+ drawer._format_tooltip("2024-01-01", 7, "run")
39
+ == "2024-01-01 7 for run"
40
+ )
41
+
42
+ poster.tooltip_by_date = {"2024-01-01": "Custom"}
43
+ assert drawer._format_tooltip("2024-01-01", 5) == "Custom"
44
+
45
+ poster.tooltip_by_date = {"2024-01-02": {"run": "Run note"}}
46
+ assert drawer._format_tooltip("2024-01-02", 3, "run") == "Run note"
@@ -1,149 +0,0 @@
1
- from datetime import datetime, timedelta
2
- import time
3
- from collections import defaultdict
4
-
5
- import pendulum
6
- import requests
7
- import json
8
-
9
- from github_heatmap.loader.base_loader import BaseLoader, LoadError
10
- from github_heatmap.loader.config import NOTION_API_URL, NOTION_API_VERSION
11
-
12
-
13
- class NotionLoader(BaseLoader):
14
- track_color = "#40C463"
15
- unit = "times"
16
-
17
- def __init__(self, from_year, to_year, _type, **kwargs):
18
- super().__init__(from_year, to_year, _type)
19
- self.number_by_date_dict = self.generate_date_dict(from_year, to_year)
20
- self.notion_token = kwargs.get("notion_token", "").strip()
21
- self.database_id = kwargs.get("database_id", "").strip()
22
- self.date_prop_name = kwargs.get("date_prop_name", "")
23
- self.value_prop_name = kwargs.get("value_prop_name", "")
24
- self.database_filter = kwargs.get("database_filter", "")
25
-
26
- @classmethod
27
- def add_loader_arguments(cls, parser, optional):
28
- parser.add_argument(
29
- "--notion_token",
30
- dest="notion_token",
31
- type=str,
32
- help="The Notion internal integration token.",
33
- )
34
- parser.add_argument(
35
- "--database_id",
36
- dest="database_id",
37
- type=str,
38
- help="The Notion database id.",
39
- )
40
- parser.add_argument(
41
- "--date_prop_name",
42
- dest="date_prop_name",
43
- type=str,
44
- default="Datetime",
45
- required=optional,
46
- help="The database property name which stored the datetime.",
47
- )
48
- parser.add_argument(
49
- "--value_prop_name",
50
- dest="value_prop_name",
51
- type=str,
52
- default="Datetime",
53
- required=optional,
54
- help="The database property name which stored the datetime.",
55
- )
56
- parser.add_argument(
57
- "--database_filter",
58
- dest="database_filter",
59
- type=str,
60
- default="",
61
- required=False,
62
- help="The database property name which stored the datetime.",
63
- )
64
-
65
- def get_api_data(self, start_cursor="", page_size=100, data_list=[]):
66
- payload = {
67
- "page_size": page_size,
68
- "filter": {
69
- "and": [
70
- {
71
- "property": self.date_prop_name,
72
- "date": {"on_or_after": f"{self.from_year}-01-01"},
73
- },
74
- {
75
- "property": self.date_prop_name,
76
- "date": {"on_or_before": f"{self.to_year}-12-31"},
77
- },
78
- ]
79
- },
80
- }
81
- if self.database_filter:
82
- payload["filter"]["and"].append(json.loads(self.database_filter))
83
- if start_cursor:
84
- payload.update({"start_cursor": start_cursor})
85
-
86
- headers = {
87
- "Accept": "application/json",
88
- "Notion-Version": NOTION_API_VERSION,
89
- "Content-Type": "application/json",
90
- "Authorization": "Bearer " + self.notion_token,
91
- }
92
-
93
- resp = requests.post(
94
- NOTION_API_URL.format(database_id=self.database_id),
95
- json=payload,
96
- headers=headers,
97
- )
98
- if not resp.ok:
99
- raise LoadError("Can not get Notion data, please check your config")
100
- data = resp.json()
101
- results = data["results"]
102
- next_cursor = data["next_cursor"]
103
- data_list.extend(results)
104
- if not data["has_more"]:
105
- return data_list
106
- # Avoid request limits
107
- # The rate limit for incoming requests is an average
108
- # of 3 requests per second.
109
- # See https://developers.notion.com/reference/request-limits
110
- time.sleep(0.3)
111
- return self.get_api_data(
112
- start_cursor=next_cursor, page_size=page_size, data_list=data_list
113
- )
114
-
115
- def generate_date_dict(self, start_year, end_year):
116
- start_date = datetime(start_year, 1, 1)
117
- end_date = datetime(end_year, 12, 31)
118
-
119
- # 使用字典推导式生成日期字典
120
- return {
121
- (start_date + timedelta(days=i)).strftime("%Y-%m-%d"): 0
122
- for i in range((end_date - start_date).days + 1)
123
- }
124
-
125
- def make_track_dict(self):
126
- data_list = self.get_api_data()
127
- for result in data_list:
128
- date = result["properties"][self.date_prop_name]["date"]
129
- value = result["properties"][self.value_prop_name]
130
- if date and value:
131
- dt = date.get("start")
132
- type = value.get("type")
133
- if type == "formula" and value.get(type).get("type") == "number":
134
- value = float(value.get(type).get("number"))
135
- elif type == "rollup" and value.get(type).get("type") == "number":
136
- value = float(value.get(type).get("number"))
137
- else:
138
- value = value.get(type)
139
- date_str = pendulum.parse(dt).to_date_string()
140
- self.number_by_date_dict[date_str] = (
141
- self.number_by_date_dict.get(date_str, 0) + value
142
- )
143
- for _, v in self.number_by_date_dict.items():
144
- self.number_list.append(v)
145
-
146
- def get_all_track_data(self):
147
- self.make_track_dict()
148
- self.make_special_number()
149
- return self.number_by_date_dict, self.year_list
@@ -1,19 +0,0 @@
1
- from github_heatmap.utils import interpolate_color, make_key_times, parse_years
2
-
3
-
4
- def test_interpolate_color():
5
- assert interpolate_color("#000000", "#ffffff", 0) == "#000000"
6
- assert interpolate_color("#000000", "#ffffff", 1) == "#ffffff"
7
- assert interpolate_color("#000000", "#ffffff", 0.5) == "#7f7f7f"
8
- assert interpolate_color("#000000", "#ffffff", -100) == "#000000"
9
- assert interpolate_color("#000000", "#ffffff", 12345) == "#ffffff"
10
-
11
-
12
- def test_parse_years():
13
- assert parse_years("2012") == (2012, 2012)
14
- assert parse_years("2015-2021") == (2015, 2021)
15
- assert parse_years("2021-2015") == (2015, 2021)
16
-
17
-
18
- def test_make_key_times():
19
- assert make_key_times(5) == ["0", "0.2", "0.4", "0.6", "0.8", "1"]
File without changes
File without changes
File without changes