forked from download-directory/download-directory.github.io
-
Notifications
You must be signed in to change notification settings - Fork 0
/
index.js
270 lines (218 loc) · 7.04 KB
/
index.js
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
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
import saveFile from 'save-file';
import listContent from 'list-github-dir-content';
import pMap from 'p-map';
import pRetry from 'p-retry';
// Matches '/<re/po>/tree/<ref>/<dir>'
const urlParserRegex = /^[/]([^/]+)[/]([^/]+)[/]tree[/]([^/]+)[/](.*)/;
async function maybeResponseLfs(response) {
const length = Number(response.headers.get('content-length'));
if (length > 128 && length < 140) {
const contents = await response.clone().text();
return contents.startsWith('version https://git-lfs.github.com/spec/v1');
}
}
async function repoListingSlashblanchSupport(ref, dir, repoListingConfig) {
let files;
const dirParts = decodeURIComponent(dir).split('/');
while (dirParts.length >= 0) {
try {
files = await listContent.viaTreesApi(repoListingConfig); // eslint-disable-line no-await-in-loop
break;
} catch (error) {
if (error.message === 'Not Found') {
ref += '/' + dirParts.shift();
repoListingConfig.directory = dirParts.join('/');
repoListingConfig.ref = ref;
} else {
throw error;
}
}
}
if (files.length === 0 && files.truncated) {
updateStatus('Warning: It’s a large repo and this it take a long while just to download the list of files. You might want to use "git sparse checkout" instead.');
files = await listContent.viaContentsApi(repoListingConfig);
}
return [files, ref];
}
function updateStatus(status, ...extra) {
const element = document.querySelector('.status');
if (status) {
element.prepend(status + '\n');
} else {
element.textContent = status || '';
}
console.log(status, ...extra);
}
async function waitForToken() {
const input = document.querySelector('#token');
if (localStorage.token) {
input.value = localStorage.token;
} else {
const toggle = document.querySelector('#token-toggle');
toggle.checked = true;
updateStatus('Waiting for token…');
await new Promise(resolve => {
input.addEventListener('input', function handler() {
if (input.checkValidity()) {
toggle.checked = false;
resolve();
input.removeEventListener('input', handler);
}
});
});
}
}
async function fetchRepoInfo(repo) {
const response = await fetch(`https://api.github.com/repos/${repo}`,
localStorage.token ? {
headers: {
Authorization: `Bearer ${localStorage.token}`,
},
} : {},
);
switch (response.status) {
case 401: {
updateStatus('⚠ The token provided is invalid or has been revoked.', {token: localStorage.token});
throw new Error('Invalid token');
}
case 403: {
// See https://developer.github.com/v3/#rate-limiting
if (response.headers.get('X-RateLimit-Remaining') === '0') {
updateStatus('⚠ Your token rate limit has been exceeded.', {token: localStorage.token});
throw new Error('Rate limit exceeded');
}
break;
}
case 404: {
updateStatus('⚠ Repository was not found.', {repo});
throw new Error('Repository not found');
}
default:
}
if (!response.ok) {
updateStatus('⚠ Could not obtain repository data from the GitHub API.', {repo, response});
throw new Error('Fetch error');
}
return response.json();
}
async function getZIP() {
const JSZip = await import('jszip');
return new JSZip();
}
function escapeFilepath(path) {
return path.replaceAll('#', '%23');
}
async function init() {
const zipPromise = getZIP();
let user;
let repository;
let ref;
let dir;
const input = document.querySelector('#token');
if (localStorage.token) {
input.value = localStorage.token;
}
input.addEventListener('input', () => {
if (input.checkValidity()) {
localStorage.token = input.value;
}
});
try {
const query = new URLSearchParams(location.search);
const parsedUrl = new URL(query.get('url'));
[, user, repository, ref, dir] = urlParserRegex.exec(parsedUrl.pathname);
console.log('Source:', {user, repository, ref, dir});
} catch {
return updateStatus();
}
if (!navigator.onLine) {
updateStatus('⚠ You are offline.');
throw new Error('You are offline');
}
updateStatus(`Retrieving directory info \nRepo: ${user}/${repository}\nDirectory: /${dir}`);
const {private: repoIsPrivate} = await fetchRepoInfo(`${user}/${repository}`);
const repoListingConfig = {
user,
repository,
ref,
directory: decodeURIComponent(dir),
token: localStorage.token,
getFullData: true,
};
let files;
[files, ref] = await repoListingSlashblanchSupport(ref, dir, repoListingConfig);
if (files.length === 0) {
updateStatus('No files to download');
return;
}
updateStatus(`Will download ${files.length} files`);
const controller = new AbortController();
const fetchPublicFile = async file => {
const response = await fetch(`https://raw.githubusercontent.com/${user}/${repository}/${ref}/${escapeFilepath(file.path)}`, {
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
}
const lfsCompatibleResponse = await maybeResponseLfs(response)
? await fetch(`https://media.githubusercontent.com/media/${user}/${repository}/${ref}/${escapeFilepath(file.path)}`, {
signal: controller.signal,
})
: response;
if (!response.ok) {
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
}
return lfsCompatibleResponse.blob();
};
const fetchPrivateFile = async file => {
const response = await fetch(file.url, {
headers: {
Authorization: `Bearer ${localStorage.token}`,
},
signal: controller.signal,
});
if (!response.ok) {
throw new Error(`HTTP ${response.statusText} for ${file.path}`);
}
const {content} = await response.json();
const decoder = await fetch(`data:application/octet-stream;base64,${content}`);
return decoder.blob();
};
let downloaded = 0;
const downloadFile = async file => {
const localDownload = () => repoIsPrivate ? fetchPrivateFile(file) : fetchPublicFile(file);
const onFailedAttempt = error => {
console.error(`Error downloading ${file.url}. Attempt ${error.attemptNumber}. ${error.retriesLeft} retries left.`);
};
const blob = await pRetry(localDownload, {onFailedAttempt});
downloaded++;
updateStatus(file.path);
const zip = await zipPromise;
zip.file(file.path.replace(dir + '/', ''), blob, {
binary: true,
});
};
if (repoIsPrivate) {
await waitForToken();
}
await pMap(files, downloadFile, {concurrency: 20}).catch(error => {
controller.abort();
if (!navigator.onLine) {
updateStatus('⚠ Could not download all files, network connection lost.');
} else if (error.message.startsWith('HTTP ')) {
updateStatus('⚠ Could not download all files.');
} else {
updateStatus('⚠ Some files were blocked from downloading, try to disable any ad blockers and refresh the page.');
}
throw error;
});
updateStatus(`Zipping ${downloaded} files`);
const zip = await zipPromise;
const zipBlob = await zip.generateAsync({
type: 'blob',
});
await saveFile(zipBlob, `${user} ${repository} ${ref} ${dir}.zip`.replace(/\//, '-'));
updateStatus(`Downloaded ${downloaded} files! Done!`);
}
// eslint-disable-next-line unicorn/prefer-top-level-await -- I like having an `init` function since there's a lot of code in this file
init();