factorio-mod-workshop 0.1.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.
- factorio_mod_workshop-0.1.0.dist-info/METADATA +189 -0
- factorio_mod_workshop-0.1.0.dist-info/RECORD +20 -0
- factorio_mod_workshop-0.1.0.dist-info/WHEEL +4 -0
- factorio_mod_workshop-0.1.0.dist-info/entry_points.txt +2 -0
- factorio_mod_workshop-0.1.0.dist-info/licenses/LICENSE +617 -0
- fmw/__init__.py +3 -0
- fmw/cli.py +1431 -0
- fmw/config.py +187 -0
- fmw/discover.py +193 -0
- fmw/help.py +387 -0
- fmw/portal.py +336 -0
- fmw/store.py +141 -0
- fmw/templates/workspace/.gitignore +5 -0
- fmw/templates/workspace/README.md +31 -0
- fmw/templates/workspace/config.example.yaml +11 -0
- fmw/templates/workspace/config.yaml +11 -0
- fmw/templates/workspace/mods.yaml +6 -0
- fmw/templates/workspace/tracker/README.md +40 -0
- fmw/threadstate.py +150 -0
- fmw/tracker.py +223 -0
fmw/portal.py
ADDED
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
"""Factorio mod portal access: official JSON API plus discussion scraping.
|
|
2
|
+
|
|
3
|
+
The portal has a public read-only API for mod metadata, but discussions are
|
|
4
|
+
HTML only. The markup is server-rendered with stable semantic classes, so
|
|
5
|
+
BeautifulSoup is enough - no browser needed, no login needed to read.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
import time
|
|
12
|
+
from dataclasses import dataclass, field, asdict
|
|
13
|
+
from typing import Iterable, NamedTuple
|
|
14
|
+
|
|
15
|
+
import httpx
|
|
16
|
+
from bs4 import BeautifulSoup
|
|
17
|
+
|
|
18
|
+
BASE = "https://mods.factorio.com"
|
|
19
|
+
API_BASE = f"{BASE}/api"
|
|
20
|
+
USER_AGENT = "factorio-mod-workshop/0.1 (+https://github.com/AivanF/factorio-mod-workshop) mod-maintenance-tooling"
|
|
21
|
+
|
|
22
|
+
THREAD_ID_RE = re.compile(r"/discussion/([0-9a-f]{24})")
|
|
23
|
+
REQUEST_DELAY = 0.4 # be a polite citizen; the portal is a free service
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PortalError(RuntimeError):
|
|
27
|
+
pass
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class NotFound(PortalError):
|
|
31
|
+
"""The portal returned 404 - the mod or thread does not exist there."""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass
|
|
35
|
+
class Message:
|
|
36
|
+
author: str
|
|
37
|
+
posted_at: str # ISO 8601, exact (from the title= attribute)
|
|
38
|
+
edited_at: str | None
|
|
39
|
+
body: str # markdown-ish plain text
|
|
40
|
+
is_op: bool = False
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ThreadSummary:
|
|
45
|
+
id: str
|
|
46
|
+
mod: str
|
|
47
|
+
title: str
|
|
48
|
+
tag: str # human name, e.g. "Bugs"
|
|
49
|
+
author: str
|
|
50
|
+
posted_at: str
|
|
51
|
+
reply_count: int
|
|
52
|
+
last_author: str | None
|
|
53
|
+
last_at: str | None
|
|
54
|
+
|
|
55
|
+
@property
|
|
56
|
+
def url(self) -> str:
|
|
57
|
+
return f"{BASE}/mod/{self.mod}/discussion/{self.id}"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Thread:
|
|
62
|
+
id: str
|
|
63
|
+
mod: str
|
|
64
|
+
title: str
|
|
65
|
+
tag: str
|
|
66
|
+
messages: list[Message] = field(default_factory=list)
|
|
67
|
+
|
|
68
|
+
@property
|
|
69
|
+
def url(self) -> str:
|
|
70
|
+
return f"{BASE}/mod/{self.mod}/discussion/{self.id}"
|
|
71
|
+
|
|
72
|
+
def to_dict(self) -> dict:
|
|
73
|
+
d = asdict(self)
|
|
74
|
+
d["url"] = self.url
|
|
75
|
+
return d
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class Portal:
|
|
79
|
+
def __init__(self, timeout: float = 20.0, delay: float = REQUEST_DELAY):
|
|
80
|
+
self._client = httpx.Client(
|
|
81
|
+
headers={"User-Agent": USER_AGENT},
|
|
82
|
+
timeout=timeout,
|
|
83
|
+
follow_redirects=True,
|
|
84
|
+
)
|
|
85
|
+
self._delay = delay
|
|
86
|
+
self._last_request = 0.0
|
|
87
|
+
|
|
88
|
+
def close(self) -> None:
|
|
89
|
+
self._client.close()
|
|
90
|
+
|
|
91
|
+
def __enter__(self) -> "Portal":
|
|
92
|
+
return self
|
|
93
|
+
|
|
94
|
+
def __exit__(self, *exc) -> None:
|
|
95
|
+
self.close()
|
|
96
|
+
|
|
97
|
+
def _get(self, url: str) -> httpx.Response:
|
|
98
|
+
wait = self._delay - (time.monotonic() - self._last_request)
|
|
99
|
+
if wait > 0:
|
|
100
|
+
time.sleep(wait)
|
|
101
|
+
last_exc: Exception | None = None
|
|
102
|
+
for attempt in range(3):
|
|
103
|
+
try:
|
|
104
|
+
resp = self._client.get(url)
|
|
105
|
+
self._last_request = time.monotonic()
|
|
106
|
+
if resp.status_code == 404:
|
|
107
|
+
raise NotFound(f"404 Not Found: {url}")
|
|
108
|
+
if resp.status_code >= 500:
|
|
109
|
+
raise httpx.HTTPError(f"{resp.status_code} from {url}")
|
|
110
|
+
resp.raise_for_status()
|
|
111
|
+
return resp
|
|
112
|
+
except NotFound:
|
|
113
|
+
raise
|
|
114
|
+
except Exception as exc: # network hiccup / 5xx
|
|
115
|
+
last_exc = exc
|
|
116
|
+
time.sleep(1.5 * (attempt + 1))
|
|
117
|
+
raise PortalError(f"Failed to fetch {url}: {last_exc}")
|
|
118
|
+
|
|
119
|
+
# ------------------------------------------------------------------
|
|
120
|
+
# Official API
|
|
121
|
+
# ------------------------------------------------------------------
|
|
122
|
+
def mod_info(self, mod: str) -> dict:
|
|
123
|
+
"""Full mod metadata: releases, deps, changelog, downloads, tags."""
|
|
124
|
+
return self._get(f"{API_BASE}/mods/{mod}/full").json()
|
|
125
|
+
|
|
126
|
+
# ------------------------------------------------------------------
|
|
127
|
+
# Discussions (HTML)
|
|
128
|
+
# ------------------------------------------------------------------
|
|
129
|
+
def _soup(self, url: str) -> BeautifulSoup:
|
|
130
|
+
return BeautifulSoup(self._get(url).text, "html.parser")
|
|
131
|
+
|
|
132
|
+
def discussion_tags(self, mod: str) -> list[tuple[str, str]]:
|
|
133
|
+
"""Return [(code, name)] of discussion tag filters offered for the mod."""
|
|
134
|
+
soup = self._soup(f"{BASE}/mod/{mod}/discussion")
|
|
135
|
+
return self._tags_from_soup(soup)
|
|
136
|
+
|
|
137
|
+
@staticmethod
|
|
138
|
+
def _tags_from_soup(soup: BeautifulSoup) -> list[tuple[str, str]]:
|
|
139
|
+
tags: list[tuple[str, str]] = []
|
|
140
|
+
holder = soup.select_one(".discussion-tags-filter")
|
|
141
|
+
if not holder:
|
|
142
|
+
return tags
|
|
143
|
+
for a in holder.select("a[href]"):
|
|
144
|
+
code = a["href"].rstrip("/").rsplit("/", 1)[-1]
|
|
145
|
+
name = a.get_text(strip=True)
|
|
146
|
+
if code and name:
|
|
147
|
+
tags.append((code, name))
|
|
148
|
+
return tags
|
|
149
|
+
|
|
150
|
+
def thread_list(self, mod: str, max_pages: int = 50) -> tuple[list[ThreadSummary], list[str]]:
|
|
151
|
+
"""All discussion threads of a mod, plus a list of warnings.
|
|
152
|
+
|
|
153
|
+
The listing shows 50 threads per page and is paginated as
|
|
154
|
+
/discussion/page/N (note: a ?page=N query parameter is silently
|
|
155
|
+
ignored, which looks exactly like truncation). We walk pages until one
|
|
156
|
+
comes back empty.
|
|
157
|
+
"""
|
|
158
|
+
warnings: list[str] = []
|
|
159
|
+
by_id: dict[str, ThreadSummary] = {}
|
|
160
|
+
|
|
161
|
+
for page in range(1, max_pages + 1):
|
|
162
|
+
url = f"{BASE}/mod/{mod}/discussion"
|
|
163
|
+
if page > 1:
|
|
164
|
+
url += f"/page/{page}"
|
|
165
|
+
rows = list(self._rows_from_soup(self._soup(url), mod))
|
|
166
|
+
if not rows:
|
|
167
|
+
break
|
|
168
|
+
for summary in rows:
|
|
169
|
+
by_id[summary.id] = summary
|
|
170
|
+
else:
|
|
171
|
+
warnings.append(
|
|
172
|
+
f"stopped after {max_pages} pages - there may be more threads"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
ordered = sorted(by_id.values(), key=lambda t: t.last_at or t.posted_at, reverse=True)
|
|
176
|
+
return ordered, warnings
|
|
177
|
+
|
|
178
|
+
@staticmethod
|
|
179
|
+
def _rows_from_soup(soup: BeautifulSoup, mod: str) -> Iterable[ThreadSummary]:
|
|
180
|
+
for tr in soup.select("tr.discussion-list-message"):
|
|
181
|
+
link = tr.select_one(".discussion-list-message-title a[href]")
|
|
182
|
+
if not link:
|
|
183
|
+
continue
|
|
184
|
+
m = THREAD_ID_RE.search(link["href"])
|
|
185
|
+
if not m:
|
|
186
|
+
continue
|
|
187
|
+
tds = tr.find_all("td", recursive=False)
|
|
188
|
+
|
|
189
|
+
title_cell = tds[0]
|
|
190
|
+
author_a = title_cell.select_one('a[href^="/user/"]')
|
|
191
|
+
posted_span = title_cell.select_one("span[title]")
|
|
192
|
+
|
|
193
|
+
tag = tds[1].get_text(strip=True) if len(tds) >= 2 else ""
|
|
194
|
+
|
|
195
|
+
reply_count = 0
|
|
196
|
+
last_author = None
|
|
197
|
+
last_at = None
|
|
198
|
+
if len(tds) >= 3:
|
|
199
|
+
meta = tds[2]
|
|
200
|
+
count_m = re.search(r"(\d+)", meta.get_text(" ", strip=True))
|
|
201
|
+
if count_m:
|
|
202
|
+
reply_count = int(count_m.group(1))
|
|
203
|
+
last_a = meta.select_one('a[href^="/user/"]')
|
|
204
|
+
if last_a:
|
|
205
|
+
last_author = last_a.get_text(strip=True)
|
|
206
|
+
last_span = meta.select_one("span[title]")
|
|
207
|
+
if last_span:
|
|
208
|
+
last_at = last_span["title"]
|
|
209
|
+
|
|
210
|
+
posted_at = posted_span["title"] if posted_span else ""
|
|
211
|
+
yield ThreadSummary(
|
|
212
|
+
id=m.group(1),
|
|
213
|
+
mod=mod,
|
|
214
|
+
title=link.get("title") or link.get_text(strip=True),
|
|
215
|
+
tag=tag or "",
|
|
216
|
+
author=author_a.get_text(strip=True) if author_a else "",
|
|
217
|
+
posted_at=posted_at,
|
|
218
|
+
reply_count=reply_count,
|
|
219
|
+
last_author=last_author,
|
|
220
|
+
last_at=last_at or posted_at,
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
def thread(self, mod: str, thread_id: str) -> Thread:
|
|
224
|
+
soup = self._soup(f"{BASE}/mod/{mod}/discussion/{thread_id}")
|
|
225
|
+
title_el = soup.select_one(".discussion-detail-title")
|
|
226
|
+
if not title_el:
|
|
227
|
+
raise PortalError(f"Not a discussion page: {mod}/{thread_id}")
|
|
228
|
+
|
|
229
|
+
tag = ""
|
|
230
|
+
badge = title_el.select_one(".discussion-message-tag")
|
|
231
|
+
if badge:
|
|
232
|
+
tag = badge.get("title") or badge.get_text(strip=True)
|
|
233
|
+
badge.extract()
|
|
234
|
+
title = title_el.get_text(" ", strip=True)
|
|
235
|
+
|
|
236
|
+
messages: list[Message] = []
|
|
237
|
+
for i, block in enumerate(soup.select(".discussion-message")):
|
|
238
|
+
author_el = block.select_one(".discussion-message-author a")
|
|
239
|
+
body_el = block.select_one(".discussion-message-body")
|
|
240
|
+
if not author_el or body_el is None:
|
|
241
|
+
continue
|
|
242
|
+
stamps = [d["title"] for d in block.select(".ml8.grey[title]")]
|
|
243
|
+
messages.append(
|
|
244
|
+
Message(
|
|
245
|
+
author=author_el.get_text(strip=True),
|
|
246
|
+
posted_at=stamps[0] if stamps else "",
|
|
247
|
+
edited_at=stamps[1] if len(stamps) > 1 else None,
|
|
248
|
+
body=html_to_text(body_el),
|
|
249
|
+
is_op=(i == 0),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
return Thread(id=thread_id, mod=mod, title=title, tag=tag, messages=messages)
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def html_to_text(el) -> str:
|
|
256
|
+
"""Flatten a discussion body to readable plain text, keeping line breaks."""
|
|
257
|
+
# A <br> is usually followed by a literal newline in the source, so replacing
|
|
258
|
+
# it with "\n" directly would double every line break inside a paragraph.
|
|
259
|
+
# Use a sentinel and swallow the surrounding whitespace instead.
|
|
260
|
+
for br in el.find_all("br"):
|
|
261
|
+
br.replace_with("\x00")
|
|
262
|
+
for p in el.find_all("p"):
|
|
263
|
+
p.insert_before("\n\n")
|
|
264
|
+
p.insert_after("\n\n")
|
|
265
|
+
text = el.get_text()
|
|
266
|
+
text = re.sub(r"[ \t]*\x00[ \t]*\n?", "\n", text)
|
|
267
|
+
text = re.sub(r"[ \t]+\n", "\n", text)
|
|
268
|
+
text = re.sub(r"\n{3,}", "\n\n", text)
|
|
269
|
+
return text.strip()
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# Portal tag names are long and truncating them mid-word ("Gener", "Ideas")
|
|
273
|
+
# reads badly, so display uses fixed abbreviations instead.
|
|
274
|
+
TAG_ABBREV = {
|
|
275
|
+
"Bugs": "bug",
|
|
276
|
+
"General": "gen",
|
|
277
|
+
"Ideas & suggestions": "idea",
|
|
278
|
+
"Announcements": "ann",
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
|
|
282
|
+
def abbrev_tag(tag: str) -> str:
|
|
283
|
+
return TAG_ABBREV.get(tag, (tag or "?").lower()[:4])
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ----------------------------------------------------------------------
|
|
287
|
+
# Title markers
|
|
288
|
+
# ----------------------------------------------------------------------
|
|
289
|
+
# The portal has no thread state, so maintainers annotate the title once they
|
|
290
|
+
# have dealt with a thread. The convention varies: this repository's own mods
|
|
291
|
+
# use "[FIXED] title" on some mods and "title (fixed)" on others, so both
|
|
292
|
+
# bracket styles and both positions have to be accepted. Derived at read time
|
|
293
|
+
# rather than stored, so editing a title on the portal takes effect without a
|
|
294
|
+
# re-sync.
|
|
295
|
+
MARKER_PREFIX_RE = re.compile(r"^\s*[\[(]\s*([^\])]{1,24}?)\s*[\])]\s*")
|
|
296
|
+
MARKER_SUFFIX_RE = re.compile(r"\s*[\[(]\s*([^\])]{1,24}?)\s*[\])]\s*$")
|
|
297
|
+
|
|
298
|
+
RESOLVED_MARKERS = {
|
|
299
|
+
"FIXED", "DONE", "SOLVED", "CLOSED", "EXPLAINED", "ANSWERED",
|
|
300
|
+
"RELEASED", "WONTFIX", "DUPLICATE", "INVALID", "OUTDATED",
|
|
301
|
+
}
|
|
302
|
+
# Not a resolution: the maintainer judged the report itself mistaken.
|
|
303
|
+
WRONG_MARKERS = {"WRONG", "INVALID REPORT", "NOT A BUG"}
|
|
304
|
+
|
|
305
|
+
|
|
306
|
+
class TitleMarker(NamedTuple):
|
|
307
|
+
marker: str | None # the raw text found, e.g. "FIXED" or "fixed?"
|
|
308
|
+
resolved: bool # maintainer considers the thread dealt with
|
|
309
|
+
wrong: bool # maintainer considers the report mistaken
|
|
310
|
+
clean: str # title with the marker removed
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def parse_marker(title: str) -> TitleMarker:
|
|
314
|
+
"""Split a thread title into its maintainer marker and the rest.
|
|
315
|
+
|
|
316
|
+
A trailing question mark ("[fixed?]") means the maintainer was unsure, so
|
|
317
|
+
it does not count as resolved.
|
|
318
|
+
"""
|
|
319
|
+
title = title or ""
|
|
320
|
+
for regex in (MARKER_PREFIX_RE, MARKER_SUFFIX_RE):
|
|
321
|
+
m = regex.search(title)
|
|
322
|
+
if not m:
|
|
323
|
+
continue
|
|
324
|
+
raw = m.group(1).strip()
|
|
325
|
+
key = raw.rstrip("?").strip().upper()
|
|
326
|
+
if key not in RESOLVED_MARKERS and key not in WRONG_MARKERS:
|
|
327
|
+
continue # an ordinary parenthetical, e.g. "Crash (Space Exploration)"
|
|
328
|
+
tentative = raw.endswith("?")
|
|
329
|
+
clean = (title[: m.start()] + title[m.end():]).strip()
|
|
330
|
+
return TitleMarker(
|
|
331
|
+
marker=raw,
|
|
332
|
+
resolved=key in RESOLVED_MARKERS and not tentative,
|
|
333
|
+
wrong=key in WRONG_MARKERS,
|
|
334
|
+
clean=clean,
|
|
335
|
+
)
|
|
336
|
+
return TitleMarker(marker=None, resolved=False, wrong=False, clean=title.strip())
|
fmw/store.py
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""Workspace persistence: cached portal snapshots and change detection."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import asdict
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from .config import Workspace
|
|
11
|
+
from .portal import Thread, ThreadSummary
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def now_iso() -> str:
|
|
15
|
+
return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_json(path: Path, data: dict) -> None:
|
|
19
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
20
|
+
path.write_text(json.dumps(data, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _read_json(path: Path) -> dict | None:
|
|
24
|
+
if not path.exists():
|
|
25
|
+
return None
|
|
26
|
+
return json.loads(path.read_text())
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
# ----------------------------------------------------------------------
|
|
30
|
+
# Thread list snapshots
|
|
31
|
+
# ----------------------------------------------------------------------
|
|
32
|
+
def load_list(ws: Workspace, mod: str) -> dict | None:
|
|
33
|
+
return _read_json(ws.list_file(mod))
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def save_list(ws: Workspace, mod: str, threads: list[ThreadSummary], warnings: list[str]) -> None:
|
|
37
|
+
_write_json(
|
|
38
|
+
ws.list_file(mod),
|
|
39
|
+
{
|
|
40
|
+
"mod": mod,
|
|
41
|
+
"fetched_at": now_iso(),
|
|
42
|
+
"warnings": warnings,
|
|
43
|
+
"threads": [asdict(t) for t in threads],
|
|
44
|
+
},
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_thread(ws: Workspace, mod: str, thread_id: str) -> dict | None:
|
|
49
|
+
return _read_json(ws.thread_file(mod, thread_id))
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def save_thread(ws: Workspace, thread: Thread) -> None:
|
|
53
|
+
data = thread.to_dict()
|
|
54
|
+
data["fetched_at"] = now_iso()
|
|
55
|
+
_write_json(ws.thread_file(thread.mod, thread.id), data)
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def cached_thread_ids(ws: Workspace, mod: str) -> set[str]:
|
|
59
|
+
d = ws.mod_cache_dir(mod) / "threads"
|
|
60
|
+
if not d.is_dir():
|
|
61
|
+
return set()
|
|
62
|
+
return {p.stem for p in d.glob("*.json")}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
# ----------------------------------------------------------------------
|
|
66
|
+
# Change detection
|
|
67
|
+
# ----------------------------------------------------------------------
|
|
68
|
+
def diff_lists(old: dict | None, new_threads: list[ThreadSummary]) -> list[dict]:
|
|
69
|
+
"""Compare a freshly fetched thread list against the cached snapshot."""
|
|
70
|
+
previous = {t["id"]: t for t in (old or {}).get("threads", [])}
|
|
71
|
+
changes: list[dict] = []
|
|
72
|
+
for t in new_threads:
|
|
73
|
+
prev = previous.get(t.id)
|
|
74
|
+
if prev is None:
|
|
75
|
+
changes.append(
|
|
76
|
+
{
|
|
77
|
+
"kind": "new_thread",
|
|
78
|
+
"id": t.id,
|
|
79
|
+
"title": t.title,
|
|
80
|
+
"tag": t.tag,
|
|
81
|
+
"author": t.author,
|
|
82
|
+
"at": t.posted_at,
|
|
83
|
+
"replies": t.reply_count,
|
|
84
|
+
}
|
|
85
|
+
)
|
|
86
|
+
continue
|
|
87
|
+
if t.reply_count != prev.get("reply_count") or (t.last_at or "") != (prev.get("last_at") or ""):
|
|
88
|
+
changes.append(
|
|
89
|
+
{
|
|
90
|
+
"kind": "new_replies",
|
|
91
|
+
"id": t.id,
|
|
92
|
+
"title": t.title,
|
|
93
|
+
"tag": t.tag,
|
|
94
|
+
"author": t.last_author,
|
|
95
|
+
"at": t.last_at,
|
|
96
|
+
"replies": t.reply_count,
|
|
97
|
+
"replies_before": prev.get("reply_count"),
|
|
98
|
+
}
|
|
99
|
+
)
|
|
100
|
+
return changes
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def append_changelog(ws: Workspace, mod: str, changes: list[dict]) -> None:
|
|
104
|
+
if not changes:
|
|
105
|
+
return
|
|
106
|
+
path = ws.mod_cache_dir(mod) / "changelog.jsonl"
|
|
107
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
108
|
+
stamp = now_iso()
|
|
109
|
+
with path.open("a") as fh:
|
|
110
|
+
for c in changes:
|
|
111
|
+
fh.write(json.dumps({"seen_at": stamp, "mod": mod, **c}, ensure_ascii=False) + "\n")
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def read_changelog(ws: Workspace, mod: str, since: str | None = None) -> list[dict]:
|
|
115
|
+
path = ws.mod_cache_dir(mod) / "changelog.jsonl"
|
|
116
|
+
if not path.exists():
|
|
117
|
+
return []
|
|
118
|
+
out = []
|
|
119
|
+
for line in path.read_text().splitlines():
|
|
120
|
+
if not line.strip():
|
|
121
|
+
continue
|
|
122
|
+
rec = json.loads(line)
|
|
123
|
+
if since and rec.get("seen_at", "") < since:
|
|
124
|
+
continue
|
|
125
|
+
out.append(rec)
|
|
126
|
+
return out
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def find_thread_mod(ws: Workspace, thread_id: str) -> str | None:
|
|
130
|
+
"""Locate which mod a cached thread belongs to, given only its id."""
|
|
131
|
+
if not ws.cache_dir.is_dir():
|
|
132
|
+
return None
|
|
133
|
+
for mod_dir in sorted(ws.cache_dir.iterdir()):
|
|
134
|
+
if (mod_dir / "threads" / f"{thread_id}.json").exists():
|
|
135
|
+
return mod_dir.name
|
|
136
|
+
# fall back to the list snapshots, in case the thread body was never fetched
|
|
137
|
+
for mod_dir in sorted(ws.cache_dir.iterdir()):
|
|
138
|
+
data = _read_json(mod_dir / "threads.json")
|
|
139
|
+
if data and any(t["id"] == thread_id for t in data.get("threads", [])):
|
|
140
|
+
return mod_dir.name
|
|
141
|
+
return None
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# fmw workspace
|
|
2
|
+
|
|
3
|
+
This directory holds all mod-specific and personal data for the
|
|
4
|
+
[factorio-mod-workshop](https://github.com/AivanF/factorio-mod-workshop) toolkit.
|
|
5
|
+
It is intentionally kept out of the tool repository so the tooling can be shared
|
|
6
|
+
and the data can stay private.
|
|
7
|
+
|
|
8
|
+
Make it its own git repository:
|
|
9
|
+
|
|
10
|
+
```sh
|
|
11
|
+
git init
|
|
12
|
+
git add -A && git commit -m "workspace"
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## Layout
|
|
16
|
+
|
|
17
|
+
| Path | Owner | Shared? | Contents |
|
|
18
|
+
| --- | --- | --- | --- |
|
|
19
|
+
| `config.yaml` | you | no, gitignored | which directories to scan, Discord details |
|
|
20
|
+
| `paths.yaml` | `fmw discover` | no, gitignored | where each mod is checked out on this machine |
|
|
21
|
+
| `mods.yaml` | `fmw discover` + you | yes | mod registry, `maintained` and `notes` are yours |
|
|
22
|
+
| `cache/<mod>/threads.json` | tool | yes | snapshot of the discussion list |
|
|
23
|
+
| `cache/<mod>/threads/<id>.json` | tool | yes | full thread contents |
|
|
24
|
+
| `cache/<mod>/changelog.jsonl` | tool | yes | append-only log of detected changes |
|
|
25
|
+
| `threads/<mod>.yaml` | you + agents | yes | your judgement about each conversation |
|
|
26
|
+
| `tracker/<mod>/*.md` | you + agents | yes | tickets: code changes, error or feature |
|
|
27
|
+
| `notes/` | you | yes | anything else |
|
|
28
|
+
|
|
29
|
+
Everything except `config.yaml` and `paths.yaml` is machine-independent, so a workspace can be a git repository that several maintainers collaborate in. Each of them copies `config.example.yaml` to `config.yaml` and runs `fmw discover` to generate their own `paths.yaml`.
|
|
30
|
+
|
|
31
|
+
Never hand-edit `cache/` - it is overwritten on every `fmw sync`.
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Copy to config.yaml and edit. Machine-specific, so it is gitignored.
|
|
2
|
+
|
|
3
|
+
# Directories scanned by `fmw discover` for mods (any dir containing info.json).
|
|
4
|
+
mod_repos:
|
|
5
|
+
- ~/path/to/your/factorio-mods-monorepo
|
|
6
|
+
- ~/path/to/another-mod-repo
|
|
7
|
+
|
|
8
|
+
# Optional: where you talk to users outside the mod portal.
|
|
9
|
+
discord:
|
|
10
|
+
invite: ""
|
|
11
|
+
notes: ""
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# Local, machine-specific configuration. Never committed to the public repo.
|
|
2
|
+
|
|
3
|
+
# Directories scanned by `fmw discover` for mods (any dir containing info.json).
|
|
4
|
+
mod_repos:
|
|
5
|
+
- ~/path/to/your/factorio-mods-monorepo
|
|
6
|
+
- ~/path/to/another-mod-repo
|
|
7
|
+
|
|
8
|
+
# Optional: where you talk to users outside the mod portal.
|
|
9
|
+
discord:
|
|
10
|
+
invite: ""
|
|
11
|
+
notes: ""
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
# Mod registry. Regenerated (merged, never clobbered) by `fmw discover`.
|
|
2
|
+
# Fields you own and should edit by hand:
|
|
3
|
+
# maintained: active | frozen | unknown (frozen mods are skipped unless --all)
|
|
4
|
+
# notes: free text, read by agents before touching the mod
|
|
5
|
+
# priority: optional integer, higher = more attention
|
|
6
|
+
mods: {}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# Tracker
|
|
2
|
+
|
|
3
|
+
One Markdown file per item, `tracker/<mod>/<slug>.md`.
|
|
4
|
+
|
|
5
|
+
```markdown
|
|
6
|
+
---
|
|
7
|
+
id: a3f9c1 # random, stable, never changes; references use this alone
|
|
8
|
+
mods: [Mining_Drones_Remastered] # first is primary and decides the directory
|
|
9
|
+
kind: error # error | feature - tickets are code changes only
|
|
10
|
+
status: confirmed # new | needs-info | confirmed | in-progress | needs-testing | fixed | released | wontfix | duplicate
|
|
11
|
+
severity: crash # optional: crash | major | minor | trivial
|
|
12
|
+
title: Unknown flag "hidden" when disabling vanilla drills
|
|
13
|
+
created: 2026-09-01
|
|
14
|
+
updated: 2026-09-01
|
|
15
|
+
affects: ["2.2.0"]
|
|
16
|
+
fixed_in: ""
|
|
17
|
+
sources:
|
|
18
|
+
- portal:6a963b260970b611cd38bc3b
|
|
19
|
+
- discord:https://discord.com/channels/...
|
|
20
|
+
- self
|
|
21
|
+
related: []
|
|
22
|
+
duplicate_of: ""
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Summary
|
|
26
|
+
## Analysis
|
|
27
|
+
## Resolution
|
|
28
|
+
## Test plan
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Every enum value is listed in the toolkit's `docs/workspace.md`, under "Field values", along with which ones the code actually acts on.
|
|
32
|
+
|
|
33
|
+
Rules that keep the tooling useful:
|
|
34
|
+
|
|
35
|
+
- `sources:` is the join key. `portal:<thread-id>` entries are what `fmw inbox`, the derived reply obligation and the ticket lines in `fmw threads` look at, so always record them.
|
|
36
|
+
- Several reports of the same problem belong in **one** item with several sources, not in several items. Use `duplicate_of` only when a separate item already exists.
|
|
37
|
+
- Ideas live here as `kind: feature`, whether a user asked or you thought of it. Larger roadmap notes belong in `../notes/`, not here.
|
|
38
|
+
- Threads that need an answer rather than a change never become tickets. Use `fmw mark` instead.
|
|
39
|
+
- Update `status` and `fixed_in` when you release, so the next agent does not re-investigate a solved problem.
|
|
40
|
+
- `needs-testing` means written but not verified in game - the furthest an AI agent can take a fix on its own. Fill in the `## Test plan` section when setting it, and run `fmw testplan` to check a whole batch in one Factorio session.
|