-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathgithub.js
More file actions
252 lines (222 loc) · 7.75 KB
/
Copy pathgithub.js
File metadata and controls
252 lines (222 loc) · 7.75 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
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
import { Octokit } from 'https://esm.sh/octokit';
// ── Public read-only content repo (no token required) ────────────────────────
const CONTENT_OWNER = 'LiquidGalaxyLAB';
const CONTENT_REPO = 'lg-wiki-content';
const RAW_BASE = `https://raw.githubusercontent.com/${CONTENT_OWNER}/${CONTENT_REPO}/main`;
export class GitHubService {
constructor(owner, repo) {
this.owner = owner;
this.repo = repo;
this.token = localStorage.getItem('github_token') || '';
}
setToken(token) {
this.token = token;
localStorage.setItem('github_token', token);
}
getOctokit() {
return new Octokit({ auth: this.token });
}
/**
* Fetches the wiki index from the public lg-wiki-content repo.
* index.json lives at the repo root (not under content/).
* No authentication required.
*/
async fetchIndex() {
try {
const res = await fetch(`${RAW_BASE}/index.json`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.json();
} catch (e) {
console.warn('Could not fetch index.json:', e.message);
}
return { pages: [] };
}
/**
* Fetches a specific markdown file from the public lg-wiki-content repo.
* Files live under content/<filename>.
* No authentication required.
*/
async fetchDoc(filename) {
try {
const res = await fetch(`${RAW_BASE}/content/${filename}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return await res.text();
} catch (e) {
console.warn(`Could not fetch ${filename}:`, e.message);
}
return null;
}
/**
* Fetches a specific markdown file from the public lg-wiki-content repo as a stream.
* Calls onChunk with the accumulated text as it downloads.
*/
async fetchDocStream(filename, onChunk) {
try {
const res = await fetch(`${RAW_BASE}/content/${filename}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
if (res.body && typeof res.body.getReader === 'function') {
try {
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let done = false;
let text = "";
while (!done) {
const { value, done: readerDone } = await reader.read();
done = readerDone;
if (value) {
const chunk = decoder.decode(value, { stream: true });
text += chunk;
if (onChunk) onChunk(text);
}
}
return text;
} catch (streamErr) {
console.warn(`Stream reading failed for ${filename}, falling back to res.text():`, streamErr.message);
}
}
// Fallback for older browsers without ReadableStream support or if stream reading fails
const text = await res.text();
if (onChunk) onChunk(text);
return text;
} catch (e) {
console.warn(`Could not fetch stream ${filename}:`, e.message);
}
return null;
}
async submitPR(title, filename, markdownContent, pendingImages, contributorEmail) {
if (!this.token) throw new Error("GitHub token is required to submit a PR.");
const octokit = new Octokit({ auth: this.token });
// 1. Get reference to main branch
const { data: refData } = await octokit.rest.git.getRef({
owner: this.owner,
repo: this.repo,
ref: "heads/main",
}).catch(() => octokit.rest.git.getRef({
owner: this.owner,
repo: this.repo,
ref: 'heads/master',
}));
const baseSha = refData.object.sha;
// 2. Create new branch
const branchName = `contrib-${Date.now()}`;
await octokit.rest.git.createRef({
owner: this.owner,
repo: this.repo,
ref: `refs/heads/${branchName}`,
sha: baseSha,
});
let modifiedMarkdown = `---
title: ${title}
contributor: ${contributorEmail}
date: ${new Date().toISOString()}
---
${markdownContent}
`;
// 3. Process all blob URLs inside the markdown
const blobRegex = /!\[([^\]]*)\]\((blob:[^\)]+)\)/g;
let match;
const uploads = [];
// Gather all blob matches that exist in pendingImages
while ((match = blobRegex.exec(modifiedMarkdown)) !== null) {
const blobUrl = match[2];
const fileObj = pendingImages[blobUrl];
if (fileObj) {
uploads.push({ blobUrl, fileObj });
}
}
// Upload each image and replace the blob URL in the markdown
for (const upload of uploads) {
const { blobUrl, fileObj } = upload;
const imagePath = `content/images/${Date.now()}-${fileObj.name}`;
// Convert raw File to Base64 for GitHub API
const base64Image = await new Promise((resolve) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.readAsDataURL(fileObj);
});
// Upload image file
await octokit.rest.repos.createOrUpdateFileContents({
owner: this.owner,
repo: this.repo,
path: imagePath,
message: `Upload image ${fileObj.name}`,
content: base64Image,
branch: branchName,
});
// Replace the local blob URL with the final permanent path (relative to content/ dir)
modifiedMarkdown = modifiedMarkdown.replace(blobUrl, `images/${imagePath.split('/').pop()}`);
}
// 4. Upload markdown file
let fileSha = undefined;
try {
const existing = await octokit.rest.repos.getContent({
owner: this.owner,
repo: this.repo,
path: `content/${filename}`,
ref: branchName
});
if ("sha" in existing.data) {
fileSha = existing.data.sha;
}
} catch (e) {}
await octokit.rest.repos.createOrUpdateFileContents({
owner: this.owner,
repo: this.repo,
path: `content/${filename}`,
message: `Update ${filename}`,
content: btoa(unescape(encodeURIComponent(modifiedMarkdown))),
branch: branchName,
sha: fileSha
});
// 4.5 Update index.json
let indexData = { pages: [] };
let indexSha = undefined;
try {
const existingIndex = await octokit.rest.repos.getContent({
owner: this.owner,
repo: this.repo,
path: "content/index.json",
ref: branchName
});
if ("content" in existingIndex.data) {
indexSha = existingIndex.data.sha;
indexData = JSON.parse(decodeURIComponent(escape(atob(existingIndex.data.content))));
}
} catch (e) {}
// Migration for older JSONs
if (indexData.categories) {
indexData.pages = indexData.categories.flatMap(c => c.pages || []);
delete indexData.categories;
}
if (!indexData.pages) {
indexData.pages = [];
}
const id = filename.replace(/\.md$/, '');
const existingPageIndex = indexData.pages.findIndex(p => p.file === filename || p.id === id);
if (existingPageIndex !== -1) {
indexData.pages[existingPageIndex].title = title;
indexData.pages[existingPageIndex].id = id;
} else {
indexData.pages.push({ id, title, file: filename });
}
await octokit.rest.repos.createOrUpdateFileContents({
owner: this.owner,
repo: this.repo,
path: "content/index.json",
message: `Update index.json for ${title}`,
content: btoa(unescape(encodeURIComponent(JSON.stringify(indexData, null, 2)))),
branch: branchName,
sha: indexSha
});
// 5. Create Pull Request
const { data: prData } = await octokit.rest.pulls.create({
owner: this.owner,
repo: this.repo,
title: `New Document: ${title}`,
head: branchName,
base: refData.ref.replace('refs/heads/', ''),
body: `**Contributor:** ${contributorEmail}\n\nThis PR automatically adds the Markdown file and updates \`index.json\`. Merge to publish.`,
});
return prData.html_url;
}
}