linear-mcp-fast 0.2.6__py3-none-any.whl → 0.3.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.
- linear_mcp_fast/reader.py +95 -1
- linear_mcp_fast/store_detector.py +9 -0
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.0.dist-info}/METADATA +1 -1
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.0.dist-info}/RECORD +7 -7
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.0.dist-info}/WHEEL +1 -1
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.0.dist-info}/entry_points.txt +0 -0
- {linear_mcp_fast-0.2.6.dist-info → linear_mcp_fast-0.3.0.dist-info}/top_level.txt +0 -0
linear_mcp_fast/reader.py
CHANGED
|
@@ -5,8 +5,10 @@ Reads Linear's local IndexedDB cache to provide fast access to issues, users,
|
|
|
5
5
|
teams, workflow states, and comments without API calls.
|
|
6
6
|
"""
|
|
7
7
|
|
|
8
|
+
import base64
|
|
8
9
|
import json
|
|
9
10
|
import os
|
|
11
|
+
import re
|
|
10
12
|
import time
|
|
11
13
|
from dataclasses import dataclass, field
|
|
12
14
|
from typing import Any
|
|
@@ -36,6 +38,7 @@ class CachedData:
|
|
|
36
38
|
comments: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
37
39
|
comments_by_issue: dict[str, list[str]] = field(default_factory=dict)
|
|
38
40
|
projects: dict[str, dict[str, Any]] = field(default_factory=dict)
|
|
41
|
+
issue_content: dict[str, str] = field(default_factory=dict) # issueId -> description
|
|
39
42
|
loaded_at: float = 0.0
|
|
40
43
|
|
|
41
44
|
def is_expired(self) -> bool:
|
|
@@ -92,6 +95,77 @@ class LinearLocalReader:
|
|
|
92
95
|
return val.decode("utf-8", errors="replace")
|
|
93
96
|
return str(val)
|
|
94
97
|
|
|
98
|
+
def _extract_yjs_text(self, content_state: str | None) -> str:
|
|
99
|
+
"""Extract readable text from Y.js encoded contentState."""
|
|
100
|
+
if not content_state:
|
|
101
|
+
return ""
|
|
102
|
+
|
|
103
|
+
try:
|
|
104
|
+
decoded = base64.b64decode(content_state)
|
|
105
|
+
text = decoded.decode("utf-8", errors="replace")
|
|
106
|
+
|
|
107
|
+
# Extract readable text (Korean + ASCII printable)
|
|
108
|
+
readable = re.findall(r"[\uac00-\ud7af\u0020-\u007e]+", text)
|
|
109
|
+
|
|
110
|
+
# Structural markers to skip (ProseMirror/Y.js)
|
|
111
|
+
skip_exact = {
|
|
112
|
+
"prosemirror", "paragraph", "heading", "bullet_list", "list_item",
|
|
113
|
+
"ordered_list", "level", "link", "null", "strong", "em", "code",
|
|
114
|
+
"table", "table_row", "table_cell", "table_header", "colspan",
|
|
115
|
+
"rowspan", "colwidth", "issuemention", "label", "href", "title",
|
|
116
|
+
"order", "attrs", "content", "marks", "type", "text", "doc",
|
|
117
|
+
"blockquote", "code_block", "hard_break", "horizontal_rule",
|
|
118
|
+
"image", "suggestion_usermentions", "todo_item", "done", "language",
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
result = []
|
|
122
|
+
for r in readable:
|
|
123
|
+
r = r.strip()
|
|
124
|
+
if len(r) < 2:
|
|
125
|
+
continue
|
|
126
|
+
|
|
127
|
+
# Skip exact structural markers
|
|
128
|
+
if r.lower() in skip_exact:
|
|
129
|
+
continue
|
|
130
|
+
|
|
131
|
+
# Skip Y.js IDs and encoded strings
|
|
132
|
+
if re.match(r"^w[\$\)\(A-Z]", r):
|
|
133
|
+
continue
|
|
134
|
+
|
|
135
|
+
# Skip JSON objects and JSON-like patterns
|
|
136
|
+
if r.startswith("{") or '{"' in r:
|
|
137
|
+
continue
|
|
138
|
+
|
|
139
|
+
# Skip link markers with JSON
|
|
140
|
+
if r.startswith("link") and "{" in r:
|
|
141
|
+
continue
|
|
142
|
+
|
|
143
|
+
# Skip UUIDs
|
|
144
|
+
if re.match(r"^[a-f0-9-]{36}$", r):
|
|
145
|
+
continue
|
|
146
|
+
|
|
147
|
+
# Skip single characters or pure numbers
|
|
148
|
+
if len(r) <= 2 and not re.search(r"[\uac00-\ud7af]", r):
|
|
149
|
+
continue
|
|
150
|
+
|
|
151
|
+
# Skip strings that are mostly special characters
|
|
152
|
+
if len(r) > 0:
|
|
153
|
+
special_ratio = sum(1 for c in r if c in "()[]{}$#@*&^%") / len(r)
|
|
154
|
+
if special_ratio > 0.3:
|
|
155
|
+
continue
|
|
156
|
+
|
|
157
|
+
result.append(r)
|
|
158
|
+
|
|
159
|
+
# Join and clean up
|
|
160
|
+
text = " ".join(result)
|
|
161
|
+
# Remove excessive whitespace and parentheses artifacts
|
|
162
|
+
text = re.sub(r"\s*\(\s*$", "", text)
|
|
163
|
+
text = re.sub(r"^\s*\)\s*", "", text)
|
|
164
|
+
text = re.sub(r"\s+", " ", text)
|
|
165
|
+
return text.strip()
|
|
166
|
+
except Exception:
|
|
167
|
+
return ""
|
|
168
|
+
|
|
95
169
|
def _extract_comment_text(self, body_data: Any) -> str:
|
|
96
170
|
"""Extract plain text from ProseMirror bodyData format."""
|
|
97
171
|
if body_data is None:
|
|
@@ -185,11 +259,16 @@ class LinearLocalReader:
|
|
|
185
259
|
team_key = team.get("key", "???")
|
|
186
260
|
identifier = f"{team_key}-{val.get('number')}"
|
|
187
261
|
|
|
262
|
+
# Try descriptionData (ProseMirror format) first, fall back to description
|
|
263
|
+
description = val.get("description")
|
|
264
|
+
if not description and val.get("descriptionData"):
|
|
265
|
+
description = self._extract_comment_text(val.get("descriptionData"))
|
|
266
|
+
|
|
188
267
|
cache.issues[val["id"]] = {
|
|
189
268
|
"id": val["id"],
|
|
190
269
|
"identifier": identifier,
|
|
191
270
|
"title": val.get("title"),
|
|
192
|
-
"description":
|
|
271
|
+
"description": description,
|
|
193
272
|
"number": val.get("number"),
|
|
194
273
|
"priority": val.get("priority"),
|
|
195
274
|
"estimate": val.get("estimate"),
|
|
@@ -246,6 +325,21 @@ class LinearLocalReader:
|
|
|
246
325
|
"updatedAt": val.get("updatedAt"),
|
|
247
326
|
}
|
|
248
327
|
|
|
328
|
+
# Load issue content (Y.js encoded descriptions)
|
|
329
|
+
if self._stores.issue_content:
|
|
330
|
+
for val in self._load_from_store(db, self._stores.issue_content):
|
|
331
|
+
issue_id = val.get("issueId")
|
|
332
|
+
content_state = val.get("contentState")
|
|
333
|
+
if issue_id and content_state:
|
|
334
|
+
extracted = self._extract_yjs_text(content_state)
|
|
335
|
+
if extracted:
|
|
336
|
+
cache.issue_content[issue_id] = extracted
|
|
337
|
+
|
|
338
|
+
# Update issues with descriptions from issue_content
|
|
339
|
+
for issue_id, desc in cache.issue_content.items():
|
|
340
|
+
if issue_id in cache.issues and not cache.issues[issue_id].get("description"):
|
|
341
|
+
cache.issues[issue_id]["description"] = desc
|
|
342
|
+
|
|
249
343
|
self._cache = cache
|
|
250
344
|
|
|
251
345
|
def _ensure_cache(self) -> CachedData:
|
|
@@ -21,6 +21,7 @@ class DetectedStores:
|
|
|
21
21
|
workflow_states: list[str] | None = None
|
|
22
22
|
comments: str | None = None
|
|
23
23
|
projects: str | None = None
|
|
24
|
+
issue_content: str | None = None # Y.js encoded issue descriptions
|
|
24
25
|
|
|
25
26
|
|
|
26
27
|
def _is_issue_record(record: dict[str, Any]) -> bool:
|
|
@@ -68,6 +69,12 @@ def _is_project_record(record: dict[str, Any]) -> bool:
|
|
|
68
69
|
return required.issubset(record.keys())
|
|
69
70
|
|
|
70
71
|
|
|
72
|
+
def _is_issue_content_record(record: dict[str, Any]) -> bool:
|
|
73
|
+
"""Check if a record looks like issue content (Y.js encoded description)."""
|
|
74
|
+
required = {"issueId", "contentState"}
|
|
75
|
+
return required.issubset(record.keys())
|
|
76
|
+
|
|
77
|
+
|
|
71
78
|
def detect_stores(db: ccl_chromium_indexeddb.WrappedDatabase) -> DetectedStores:
|
|
72
79
|
"""
|
|
73
80
|
Detect object stores by sampling their first record.
|
|
@@ -109,6 +116,8 @@ def detect_stores(db: ccl_chromium_indexeddb.WrappedDatabase) -> DetectedStores:
|
|
|
109
116
|
result.comments = store_name
|
|
110
117
|
elif _is_project_record(val) and result.projects is None:
|
|
111
118
|
result.projects = store_name
|
|
119
|
+
elif _is_issue_content_record(val) and result.issue_content is None:
|
|
120
|
+
result.issue_content = store_name
|
|
112
121
|
|
|
113
122
|
break # Only check first record
|
|
114
123
|
except Exception:
|
|
@@ -23,17 +23,17 @@ ccl_simplesnappy/__init__.py,sha256=OqArK0MfdVl2oMw1MwpWSYWWVLFT-VLIWnEXXCsacJo,
|
|
|
23
23
|
ccl_simplesnappy/ccl_simplesnappy.py,sha256=dLv1wejr2vCa2b_ZinozXVfcSsZIzJrt5ZkyxA3cQXA,10461
|
|
24
24
|
linear_mcp_fast/__init__.py,sha256=T-ioPzoZXC3a_zLilZuDBCXUn8nDjvITS7svk7BwWmY,138
|
|
25
25
|
linear_mcp_fast/__main__.py,sha256=2wkhXADcE2oGdtEpGrIvvEe9YGKjpwnJ3DBWghkVQKk,124
|
|
26
|
-
linear_mcp_fast/reader.py,sha256=
|
|
26
|
+
linear_mcp_fast/reader.py,sha256=wa2FtrGVq92aa98mVdQmxb8OgemaS2Az6zclVgicEo0,20382
|
|
27
27
|
linear_mcp_fast/server.py,sha256=Ha9kCLb_-V7c0HTSz3navb9EEWNmsTh320vRhI8ppmw,12757
|
|
28
|
-
linear_mcp_fast/store_detector.py,sha256=
|
|
28
|
+
linear_mcp_fast/store_detector.py,sha256=BkwKelUlOCkn-e2pDtP5KcITb6juij7zmBOUOnNHc8Y,4585
|
|
29
29
|
tools_and_utilities/Chromium_dump_local_storage.py,sha256=gG-pKFFk6lo332LQy2JvInlQh9Zldm5zAsuibb-dBkQ,4337
|
|
30
30
|
tools_and_utilities/Chromium_dump_session_storage.py,sha256=17BKFWioo6fPwYkH58QycJCA-z85RtWMBXsJ_29hHQs,3484
|
|
31
31
|
tools_and_utilities/benchmark.py,sha256=fyD5U6yI7Y0TkyhYtvvaHyk9Y2jJe2yxYWoFPQWypzA,1089
|
|
32
32
|
tools_and_utilities/ccl_chrome_audit.py,sha256=irGyYJae0apZDZCn23jMKmY3tYQgWyZEL8vdUBcHLZk,24695
|
|
33
33
|
tools_and_utilities/dump_indexeddb_details.py,sha256=ipNWLKPQoSNhCtPHKWvMWpKu8FhCnvc4Rciyx-90boI,2298
|
|
34
34
|
tools_and_utilities/dump_leveldb.py,sha256=hj7QnOHG64KK2fKsZ9qQOVqUUmHUtxUZqPYl4EZJO9U,1882
|
|
35
|
-
linear_mcp_fast-0.
|
|
36
|
-
linear_mcp_fast-0.
|
|
37
|
-
linear_mcp_fast-0.
|
|
38
|
-
linear_mcp_fast-0.
|
|
39
|
-
linear_mcp_fast-0.
|
|
35
|
+
linear_mcp_fast-0.3.0.dist-info/METADATA,sha256=HVEhe0EshfmCWmrMHCVawvQR_-_7FSVei2mEEC26LGY,4614
|
|
36
|
+
linear_mcp_fast-0.3.0.dist-info/WHEEL,sha256=wUyA8OaulRlbfwMtmQsvNngGrxQHAvkKcvRmdizlJi0,92
|
|
37
|
+
linear_mcp_fast-0.3.0.dist-info/entry_points.txt,sha256=Aa98tAkWz_08mS_SRyfyx0k3PuMBQoMygT88HCKMyWk,57
|
|
38
|
+
linear_mcp_fast-0.3.0.dist-info/top_level.txt,sha256=j-O2BoBpFBpGyTl2V1cp0ZjxZAQwpkweeNxG4BcQ7io,73
|
|
39
|
+
linear_mcp_fast-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|