decap-cms-backend-github 3.5.0 → 3.7.0
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.
- package/CHANGELOG.md +10 -0
- package/dist/decap-cms-backend-github.js +67 -67
- package/dist/decap-cms-backend-github.js.map +1 -1
- package/dist/esm/API.js +39 -17
- package/dist/esm/polling.js +391 -0
- package/package.json +2 -2
- package/src/API.ts +41 -13
- package/dist/esm/types/githubPullRequests.js +0 -1
package/dist/esm/API.js
CHANGED
|
@@ -7,8 +7,8 @@ import result from 'lodash/result';
|
|
|
7
7
|
import trimStart from 'lodash/trimStart';
|
|
8
8
|
import trim from 'lodash/trim';
|
|
9
9
|
import { oneLine } from 'common-tags';
|
|
10
|
-
import { getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
|
|
11
10
|
import { dirname } from 'path';
|
|
11
|
+
import { getAllResponses, APIError, EditorialWorkflowError, localForage, basename, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, PreviewState, parseContentKey, branchFromContentKey, isCMSLabel, labelToStatus, statusToLabel, contentKeyFromBranch, requestWithBackoff, unsentRequest, throwOnConflictingBranches } from 'decap-cms-lib-util';
|
|
12
12
|
export const API_NAME = 'GitHub';
|
|
13
13
|
export const MOCK_PULL_REQUEST = -1;
|
|
14
14
|
var GitHubCommitStatusState = /*#__PURE__*/function (GitHubCommitStatusState) {
|
|
@@ -93,7 +93,8 @@ export default class API {
|
|
|
93
93
|
}
|
|
94
94
|
return this._userPromise.then(user => ({
|
|
95
95
|
name: user.name || 'Unknown',
|
|
96
|
-
login: user.login
|
|
96
|
+
login: user.login,
|
|
97
|
+
email: user.email ?? undefined
|
|
97
98
|
}));
|
|
98
99
|
}
|
|
99
100
|
async hasWriteAccess() {
|
|
@@ -757,9 +758,10 @@ export default class API {
|
|
|
757
758
|
const contentKey = this.generateContentKey(options.collectionName, slug);
|
|
758
759
|
const branch = branchFromContentKey(contentKey);
|
|
759
760
|
const unpublished = options.unpublished || false;
|
|
761
|
+
const hasSubfolders = options.hasSubfolders !== false; // default to true
|
|
760
762
|
if (!unpublished) {
|
|
761
763
|
const branchData = await this.getDefaultBranch();
|
|
762
|
-
const changeTree = await this.updateTree(branchData.commit.sha, files);
|
|
764
|
+
const changeTree = await this.updateTree(branchData.commit.sha, files, this.branch, hasSubfolders);
|
|
763
765
|
const commitResponse = await this.commit(options.commitMessage, changeTree);
|
|
764
766
|
if (this.useOpenAuthoring) {
|
|
765
767
|
await this.createBranch(branch, commitResponse.sha);
|
|
@@ -787,7 +789,7 @@ export default class API {
|
|
|
787
789
|
// rebase the branch before applying new changes
|
|
788
790
|
const rebasedHead = await this.rebaseBranch(branch);
|
|
789
791
|
const treeFiles = mediaFilesToRemove.concat(files);
|
|
790
|
-
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
|
|
792
|
+
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
|
|
791
793
|
const commit = await this.commit(options.commitMessage, changeTree);
|
|
792
794
|
return this.patchBranch(branch, commit.sha, {
|
|
793
795
|
force: true
|
|
@@ -1103,7 +1105,7 @@ export default class API {
|
|
|
1103
1105
|
item.sha = response.sha;
|
|
1104
1106
|
return item;
|
|
1105
1107
|
}
|
|
1106
|
-
async updateTree(baseSha, files, branch = this.branch) {
|
|
1108
|
+
async updateTree(baseSha, files, branch = this.branch, hasSubfolders = true) {
|
|
1107
1109
|
const toMove = [];
|
|
1108
1110
|
const tree = files.reduce((acc, file) => {
|
|
1109
1111
|
const entry = {
|
|
@@ -1128,27 +1130,47 @@ export default class API {
|
|
|
1128
1130
|
to,
|
|
1129
1131
|
sha
|
|
1130
1132
|
} of toMove) {
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
branch,
|
|
1135
|
-
depth: 100
|
|
1136
|
-
});
|
|
1137
|
-
for (const file of files) {
|
|
1138
|
-
// delete current path
|
|
1133
|
+
if (!hasSubfolders) {
|
|
1134
|
+
// New behavior (subfolders: false): Only move the specific file
|
|
1135
|
+
// Delete the file at the old path
|
|
1139
1136
|
tree.push({
|
|
1140
|
-
path:
|
|
1137
|
+
path: trimStart(from, '/'),
|
|
1141
1138
|
mode: '100644',
|
|
1142
1139
|
type: 'blob',
|
|
1143
1140
|
sha: null
|
|
1144
1141
|
});
|
|
1145
|
-
//
|
|
1142
|
+
// Create the file at the new path
|
|
1146
1143
|
tree.push({
|
|
1147
|
-
path:
|
|
1144
|
+
path: trimStart(to, '/'),
|
|
1148
1145
|
mode: '100644',
|
|
1149
1146
|
type: 'blob',
|
|
1150
|
-
sha
|
|
1147
|
+
sha
|
|
1148
|
+
});
|
|
1149
|
+
} else {
|
|
1150
|
+
// Legacy behavior (subfolders: true, default): Move all files in the directory
|
|
1151
|
+
// This is for collections where all files in a folder represent a single entry
|
|
1152
|
+
const sourceDir = dirname(from);
|
|
1153
|
+
const destDir = dirname(to);
|
|
1154
|
+
const files = await this.listFiles(sourceDir, {
|
|
1155
|
+
branch,
|
|
1156
|
+
depth: 100
|
|
1151
1157
|
});
|
|
1158
|
+
for (const file of files) {
|
|
1159
|
+
// delete current path
|
|
1160
|
+
tree.push({
|
|
1161
|
+
path: file.path,
|
|
1162
|
+
mode: '100644',
|
|
1163
|
+
type: 'blob',
|
|
1164
|
+
sha: null
|
|
1165
|
+
});
|
|
1166
|
+
// create in new path
|
|
1167
|
+
tree.push({
|
|
1168
|
+
path: file.path.replace(sourceDir, destDir),
|
|
1169
|
+
mode: '100644',
|
|
1170
|
+
type: 'blob',
|
|
1171
|
+
sha: file.path === from ? sha : file.id
|
|
1172
|
+
});
|
|
1173
|
+
}
|
|
1152
1174
|
}
|
|
1153
1175
|
}
|
|
1154
1176
|
const newTree = await this.createTree(baseSha, tree);
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GitHub Notes Polling System
|
|
3
|
+
*
|
|
4
|
+
* ETag-based polling manager that efficiently checks for changes in GitHub Issues
|
|
5
|
+
* Used for a real-time feel of notes updates without excessive API calls leveraging conditional requests (304) that don't count on Github rate limits.
|
|
6
|
+
*
|
|
7
|
+
* @module polling
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
// Redux action types
|
|
11
|
+
export const NOTES_POLLING_START = 'NOTES_POLLING_START';
|
|
12
|
+
export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP';
|
|
13
|
+
export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE';
|
|
14
|
+
export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED';
|
|
15
|
+
export class ETagPollingManager {
|
|
16
|
+
currentWatch = null;
|
|
17
|
+
currentIssueKey = null;
|
|
18
|
+
pollingInterval = 15000;
|
|
19
|
+
intervalId = null;
|
|
20
|
+
isDocumentVisible = true;
|
|
21
|
+
isPolling = false;
|
|
22
|
+
pendingRetryTimeout = null;
|
|
23
|
+
constructor(api, pollingInterval = 15000) {
|
|
24
|
+
this.api = api;
|
|
25
|
+
this.pollingInterval = pollingInterval;
|
|
26
|
+
this.setupVisibilityListener();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Setup Page Visibility API listener
|
|
31
|
+
* Pauses polling when tab is hidden
|
|
32
|
+
*/
|
|
33
|
+
setupVisibilityListener() {
|
|
34
|
+
if (typeof document !== 'undefined') {
|
|
35
|
+
document.addEventListener('visibilitychange', () => {
|
|
36
|
+
this.isDocumentVisible = !document.hidden;
|
|
37
|
+
if (this.isDocumentVisible) {
|
|
38
|
+
this.startPolling();
|
|
39
|
+
this.checkAllIssuesNow();
|
|
40
|
+
} else {
|
|
41
|
+
this.stopPolling();
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Start watching an issue for changes
|
|
49
|
+
* This will automatically stop watching any previously watched issue
|
|
50
|
+
*
|
|
51
|
+
* @param issueNumber - GitHub issue number
|
|
52
|
+
* @param collection - Collection name
|
|
53
|
+
* @param slug - Entry slug
|
|
54
|
+
* @returns Function to stop watching
|
|
55
|
+
*/
|
|
56
|
+
async watchIssue(issueNumber, collection, slug, callbacks, initialState = null) {
|
|
57
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
58
|
+
|
|
59
|
+
// STOP ANY EXISTING WATCH FIRST
|
|
60
|
+
if (this.currentWatch) {
|
|
61
|
+
this.stopCurrentWatch();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Get initial state if not provided
|
|
65
|
+
if (!initialState) {
|
|
66
|
+
try {
|
|
67
|
+
initialState = await this.api.getIssueState(issueNumber);
|
|
68
|
+
} catch (error) {
|
|
69
|
+
console.error('[DecapNotes Polling] Failed to get initial state:', error);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.currentWatch = {
|
|
73
|
+
issueNumber,
|
|
74
|
+
collection,
|
|
75
|
+
slug,
|
|
76
|
+
etag: null,
|
|
77
|
+
lastState: initialState,
|
|
78
|
+
onUpdate: callbacks.onUpdate,
|
|
79
|
+
onChange: callbacks.onChange,
|
|
80
|
+
retryCount: 0,
|
|
81
|
+
maxRetries: 5
|
|
82
|
+
};
|
|
83
|
+
this.currentIssueKey = issueKey;
|
|
84
|
+
|
|
85
|
+
// Start polling if not already running
|
|
86
|
+
if (!this.intervalId && this.isDocumentVisible) {
|
|
87
|
+
this.startPolling();
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Do an immediate check
|
|
91
|
+
this.checkCurrentIssue();
|
|
92
|
+
|
|
93
|
+
// Return unwatch function
|
|
94
|
+
return () => this.stopCurrentWatch();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Watch issue with retry logic for newly created issues
|
|
99
|
+
*/
|
|
100
|
+
async watchIssueWithRetry(collection, slug, callbacks, maxRetries = 5, retryDelay = 2000) {
|
|
101
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
102
|
+
|
|
103
|
+
// STOP ANY EXISTING WATCH FIRST
|
|
104
|
+
if (this.currentWatch) {
|
|
105
|
+
this.stopCurrentWatch();
|
|
106
|
+
}
|
|
107
|
+
const attemptWatch = async attempt => {
|
|
108
|
+
try {
|
|
109
|
+
const issue = await this.api.findEntryIssue(collection, slug);
|
|
110
|
+
if (issue) {
|
|
111
|
+
return await this.watchIssue(issue.number, collection, slug, callbacks);
|
|
112
|
+
}
|
|
113
|
+
if (attempt < maxRetries) {
|
|
114
|
+
return new Promise((resolve, reject) => {
|
|
115
|
+
this.pendingRetryTimeout = setTimeout(async () => {
|
|
116
|
+
this.pendingRetryTimeout = null;
|
|
117
|
+
try {
|
|
118
|
+
const unwatchFn = await attemptWatch(attempt + 1);
|
|
119
|
+
resolve(unwatchFn);
|
|
120
|
+
} catch (error) {
|
|
121
|
+
reject(error);
|
|
122
|
+
}
|
|
123
|
+
}, retryDelay);
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
console.log(`[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`);
|
|
127
|
+
// Return a no-op unwatch function
|
|
128
|
+
return () => {
|
|
129
|
+
/* no-op */
|
|
130
|
+
};
|
|
131
|
+
} catch (error) {
|
|
132
|
+
console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error);
|
|
133
|
+
if (attempt < maxRetries) {
|
|
134
|
+
return new Promise((resolve, reject) => {
|
|
135
|
+
this.pendingRetryTimeout = setTimeout(async () => {
|
|
136
|
+
this.pendingRetryTimeout = null;
|
|
137
|
+
try {
|
|
138
|
+
const unwatchFn = await attemptWatch(attempt + 1);
|
|
139
|
+
resolve(unwatchFn);
|
|
140
|
+
} catch (err) {
|
|
141
|
+
reject(err);
|
|
142
|
+
}
|
|
143
|
+
}, retryDelay);
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
throw error;
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return attemptWatch(1);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Stop watching the current issue - complete cleanup
|
|
154
|
+
*/
|
|
155
|
+
stopCurrentWatch() {
|
|
156
|
+
if (!this.currentWatch) {
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Clear any pending retry timeout
|
|
161
|
+
if (this.pendingRetryTimeout) {
|
|
162
|
+
clearTimeout(this.pendingRetryTimeout);
|
|
163
|
+
this.pendingRetryTimeout = null;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Clear current watch
|
|
167
|
+
this.currentWatch = null;
|
|
168
|
+
this.currentIssueKey = null;
|
|
169
|
+
|
|
170
|
+
// Stop polling since there's nothing to watch
|
|
171
|
+
this.stopPolling();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Start the polling loop
|
|
176
|
+
*/
|
|
177
|
+
startPolling() {
|
|
178
|
+
if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return;
|
|
179
|
+
console.log(`[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`);
|
|
180
|
+
this.intervalId = setInterval(() => {
|
|
181
|
+
this.pollAllIssues();
|
|
182
|
+
}, this.pollingInterval);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Stop the polling loop
|
|
187
|
+
*/
|
|
188
|
+
stopPolling() {
|
|
189
|
+
if (this.intervalId) {
|
|
190
|
+
console.log('[DecapNotes Polling] Stopping polling loop');
|
|
191
|
+
clearInterval(this.intervalId);
|
|
192
|
+
this.intervalId = null;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Poll current watched issue
|
|
198
|
+
*/
|
|
199
|
+
async pollAllIssues() {
|
|
200
|
+
await this.checkCurrentIssue();
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Check current issue for changes using ETag
|
|
205
|
+
*/
|
|
206
|
+
async checkCurrentIssue() {
|
|
207
|
+
if (!this.currentWatch) {
|
|
208
|
+
return;
|
|
209
|
+
}
|
|
210
|
+
if (this.isPolling) {
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
this.isPolling = true;
|
|
214
|
+
try {
|
|
215
|
+
const watch = this.currentWatch;
|
|
216
|
+
const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag);
|
|
217
|
+
if (response.status === 304) {
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
if (response.status === 200) {
|
|
221
|
+
const newState = response.data;
|
|
222
|
+
const newETag = response.etag;
|
|
223
|
+
|
|
224
|
+
// Update ETag
|
|
225
|
+
watch.etag = newETag || null;
|
|
226
|
+
|
|
227
|
+
// Detect specific changes
|
|
228
|
+
const changes = this.detectChanges(watch.lastState, newState);
|
|
229
|
+
if (changes.length > 0) {
|
|
230
|
+
// Convert comments to notes
|
|
231
|
+
const newNotes = newState.comments.map(comment => ({
|
|
232
|
+
...this.api.parseCommentToNote(comment),
|
|
233
|
+
issueUrl: newState.html_url
|
|
234
|
+
}));
|
|
235
|
+
if (watch.onUpdate) {
|
|
236
|
+
watch.onUpdate(newNotes, changes);
|
|
237
|
+
}
|
|
238
|
+
if (watch.onChange) {
|
|
239
|
+
changes.forEach(change => {
|
|
240
|
+
watch.onChange(change);
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// Update stored state
|
|
246
|
+
watch.lastState = newState;
|
|
247
|
+
}
|
|
248
|
+
} catch (error) {
|
|
249
|
+
if (error && typeof error === 'object' && 'status' in error && error.status !== 304) {
|
|
250
|
+
console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error);
|
|
251
|
+
}
|
|
252
|
+
} finally {
|
|
253
|
+
this.isPolling = false;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Immediately check current issue
|
|
259
|
+
*/
|
|
260
|
+
async checkAllIssuesNow() {
|
|
261
|
+
await this.checkCurrentIssue();
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Manually trigger a check - only works if this is the current entry
|
|
266
|
+
*/
|
|
267
|
+
async checkIssueNow(collection, slug) {
|
|
268
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
269
|
+
if (this.currentIssueKey !== issueKey) {
|
|
270
|
+
console.warn(`[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`);
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
await this.checkCurrentIssue();
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Detect what changed between two states
|
|
278
|
+
*/
|
|
279
|
+
detectChanges(previous, current) {
|
|
280
|
+
if (!previous) {
|
|
281
|
+
return [];
|
|
282
|
+
}
|
|
283
|
+
const changes = [];
|
|
284
|
+
|
|
285
|
+
// New comments
|
|
286
|
+
const newComments = current.comments.filter(comment => !previous.comments.some(prev => prev.id === comment.id));
|
|
287
|
+
newComments.forEach(comment => {
|
|
288
|
+
changes.push({
|
|
289
|
+
type: 'comment_added',
|
|
290
|
+
data: comment,
|
|
291
|
+
timestamp: comment.created_at
|
|
292
|
+
});
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
// Updated comments
|
|
296
|
+
current.comments.forEach(comment => {
|
|
297
|
+
const prevComment = previous.comments.find(prev => prev.id === comment.id);
|
|
298
|
+
if (prevComment && prevComment.updated_at !== comment.updated_at) {
|
|
299
|
+
changes.push({
|
|
300
|
+
type: 'comment_updated',
|
|
301
|
+
data: comment,
|
|
302
|
+
previousData: prevComment,
|
|
303
|
+
timestamp: comment.updated_at
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
// Deleted comments
|
|
309
|
+
const deletedComments = previous.comments.filter(prevComment => !current.comments.some(comment => comment.id === prevComment.id));
|
|
310
|
+
deletedComments.forEach(comment => {
|
|
311
|
+
changes.push({
|
|
312
|
+
type: 'comment_deleted',
|
|
313
|
+
data: comment,
|
|
314
|
+
timestamp: new Date().toISOString()
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
// Issue state changed
|
|
319
|
+
if (previous.state !== current.state) {
|
|
320
|
+
changes.push({
|
|
321
|
+
type: 'issue_state_changed',
|
|
322
|
+
data: {
|
|
323
|
+
from: previous.state,
|
|
324
|
+
to: current.state
|
|
325
|
+
},
|
|
326
|
+
timestamp: current.updated_at
|
|
327
|
+
});
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Labels changed
|
|
331
|
+
if (this.hasLabelsChanged(previous.labels, current.labels)) {
|
|
332
|
+
changes.push({
|
|
333
|
+
type: 'issue_labels_changed',
|
|
334
|
+
data: {
|
|
335
|
+
from: previous.labels,
|
|
336
|
+
to: current.labels
|
|
337
|
+
},
|
|
338
|
+
timestamp: current.updated_at
|
|
339
|
+
});
|
|
340
|
+
}
|
|
341
|
+
return changes;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Check if labels changed
|
|
346
|
+
*/
|
|
347
|
+
hasLabelsChanged(previous, current) {
|
|
348
|
+
if (previous.length !== current.length) return true;
|
|
349
|
+
const prevNames = previous.map(l => l.name).sort();
|
|
350
|
+
const currNames = current.map(l => l.name).sort();
|
|
351
|
+
return prevNames.join(',') !== currNames.join(',');
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Get issue key for storage
|
|
356
|
+
*/
|
|
357
|
+
getIssueKey(collection, slug) {
|
|
358
|
+
return `${collection}/${slug}`;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Get polling status
|
|
363
|
+
*/
|
|
364
|
+
getStatus() {
|
|
365
|
+
return {
|
|
366
|
+
isPolling: this.intervalId !== null,
|
|
367
|
+
currentWatch: this.currentIssueKey,
|
|
368
|
+
watchedCount: this.currentWatch ? 1 : 0,
|
|
369
|
+
pollingInterval: this.pollingInterval,
|
|
370
|
+
isDocumentVisible: this.isDocumentVisible,
|
|
371
|
+
hasPendingRetry: this.pendingRetryTimeout !== null
|
|
372
|
+
};
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Clean up - stop all polling
|
|
377
|
+
*/
|
|
378
|
+
destroy() {
|
|
379
|
+
console.log('[DecapNotes Polling] Destroying polling manager');
|
|
380
|
+
|
|
381
|
+
// Clear pending retry
|
|
382
|
+
if (this.pendingRetryTimeout) {
|
|
383
|
+
clearTimeout(this.pendingRetryTimeout);
|
|
384
|
+
this.pendingRetryTimeout = null;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Stop current watch
|
|
388
|
+
this.stopCurrentWatch();
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
export default ETagPollingManager;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decap-cms-backend-github",
|
|
3
3
|
"description": "GitHub backend for Decap CMS",
|
|
4
|
-
"version": "3.
|
|
4
|
+
"version": "3.7.0",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-backend-github",
|
|
7
7
|
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
|
@@ -44,5 +44,5 @@
|
|
|
44
44
|
"browser": {
|
|
45
45
|
"path": "path-browserify"
|
|
46
46
|
},
|
|
47
|
-
"gitHead": "
|
|
47
|
+
"gitHead": "ae01ad6f2b139f93472c8f0d049d35277d8583ca"
|
|
48
48
|
}
|
package/src/API.ts
CHANGED
|
@@ -7,6 +7,7 @@ import result from 'lodash/result';
|
|
|
7
7
|
import trimStart from 'lodash/trimStart';
|
|
8
8
|
import trim from 'lodash/trim';
|
|
9
9
|
import { oneLine } from 'common-tags';
|
|
10
|
+
import { dirname } from 'path';
|
|
10
11
|
import {
|
|
11
12
|
getAllResponses,
|
|
12
13
|
APIError,
|
|
@@ -29,7 +30,6 @@ import {
|
|
|
29
30
|
unsentRequest,
|
|
30
31
|
throwOnConflictingBranches,
|
|
31
32
|
} from 'decap-cms-lib-util';
|
|
32
|
-
import { dirname } from 'path';
|
|
33
33
|
|
|
34
34
|
import type {
|
|
35
35
|
AssetProxy,
|
|
@@ -253,13 +253,14 @@ export default class API {
|
|
|
253
253
|
|
|
254
254
|
static DEFAULT_COMMIT_MESSAGE = 'Automatically generated by Decap CMS';
|
|
255
255
|
|
|
256
|
-
user(): Promise<{ name: string; login: string }> {
|
|
256
|
+
user(): Promise<{ name: string; login: string; email?: string }> {
|
|
257
257
|
if (!this._userPromise) {
|
|
258
258
|
this._userPromise = this.getUser({ token: this.token });
|
|
259
259
|
}
|
|
260
260
|
return this._userPromise.then(user => ({
|
|
261
261
|
name: user.name || 'Unknown',
|
|
262
262
|
login: user.login,
|
|
263
|
+
email: user.email ?? undefined,
|
|
263
264
|
}));
|
|
264
265
|
}
|
|
265
266
|
|
|
@@ -1010,9 +1011,15 @@ export default class API {
|
|
|
1010
1011
|
const contentKey = this.generateContentKey(options.collectionName as string, slug);
|
|
1011
1012
|
const branch = branchFromContentKey(contentKey);
|
|
1012
1013
|
const unpublished = options.unpublished || false;
|
|
1014
|
+
const hasSubfolders = options.hasSubfolders !== false; // default to true
|
|
1013
1015
|
if (!unpublished) {
|
|
1014
1016
|
const branchData = await this.getDefaultBranch();
|
|
1015
|
-
const changeTree = await this.updateTree(
|
|
1017
|
+
const changeTree = await this.updateTree(
|
|
1018
|
+
branchData.commit.sha,
|
|
1019
|
+
files,
|
|
1020
|
+
this.branch,
|
|
1021
|
+
hasSubfolders,
|
|
1022
|
+
);
|
|
1016
1023
|
const commitResponse = await this.commit(options.commitMessage, changeTree);
|
|
1017
1024
|
|
|
1018
1025
|
if (this.useOpenAuthoring) {
|
|
@@ -1047,7 +1054,7 @@ export default class API {
|
|
|
1047
1054
|
// rebase the branch before applying new changes
|
|
1048
1055
|
const rebasedHead = await this.rebaseBranch(branch);
|
|
1049
1056
|
const treeFiles = mediaFilesToRemove.concat(files);
|
|
1050
|
-
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch);
|
|
1057
|
+
const changeTree = await this.updateTree(rebasedHead.sha, treeFiles, branch, hasSubfolders);
|
|
1051
1058
|
const commit = await this.commit(options.commitMessage, changeTree);
|
|
1052
1059
|
|
|
1053
1060
|
return this.patchBranch(branch, commit.sha, { force: true });
|
|
@@ -1417,6 +1424,7 @@ export default class API {
|
|
|
1417
1424
|
baseSha: string,
|
|
1418
1425
|
files: { path: string; sha: string | null; newPath?: string }[],
|
|
1419
1426
|
branch = this.branch,
|
|
1427
|
+
hasSubfolders = true,
|
|
1420
1428
|
) {
|
|
1421
1429
|
const toMove: { from: string; to: string; sha: string }[] = [];
|
|
1422
1430
|
const tree = files.reduce((acc, file) => {
|
|
@@ -1437,24 +1445,44 @@ export default class API {
|
|
|
1437
1445
|
}, [] as TreeEntry[]);
|
|
1438
1446
|
|
|
1439
1447
|
for (const { from, to, sha } of toMove) {
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
for (const file of files) {
|
|
1444
|
-
// delete current path
|
|
1448
|
+
if (!hasSubfolders) {
|
|
1449
|
+
// New behavior (subfolders: false): Only move the specific file
|
|
1450
|
+
// Delete the file at the old path
|
|
1445
1451
|
tree.push({
|
|
1446
|
-
path:
|
|
1452
|
+
path: trimStart(from, '/'),
|
|
1447
1453
|
mode: '100644',
|
|
1448
1454
|
type: 'blob',
|
|
1449
1455
|
sha: null,
|
|
1450
1456
|
});
|
|
1451
|
-
//
|
|
1457
|
+
// Create the file at the new path
|
|
1452
1458
|
tree.push({
|
|
1453
|
-
path:
|
|
1459
|
+
path: trimStart(to, '/'),
|
|
1454
1460
|
mode: '100644',
|
|
1455
1461
|
type: 'blob',
|
|
1456
|
-
sha
|
|
1462
|
+
sha,
|
|
1457
1463
|
});
|
|
1464
|
+
} else {
|
|
1465
|
+
// Legacy behavior (subfolders: true, default): Move all files in the directory
|
|
1466
|
+
// This is for collections where all files in a folder represent a single entry
|
|
1467
|
+
const sourceDir = dirname(from);
|
|
1468
|
+
const destDir = dirname(to);
|
|
1469
|
+
const files = await this.listFiles(sourceDir, { branch, depth: 100 });
|
|
1470
|
+
for (const file of files) {
|
|
1471
|
+
// delete current path
|
|
1472
|
+
tree.push({
|
|
1473
|
+
path: file.path,
|
|
1474
|
+
mode: '100644',
|
|
1475
|
+
type: 'blob',
|
|
1476
|
+
sha: null,
|
|
1477
|
+
});
|
|
1478
|
+
// create in new path
|
|
1479
|
+
tree.push({
|
|
1480
|
+
path: file.path.replace(sourceDir, destDir),
|
|
1481
|
+
mode: '100644',
|
|
1482
|
+
type: 'blob',
|
|
1483
|
+
sha: file.path === from ? sha : file.id,
|
|
1484
|
+
});
|
|
1485
|
+
}
|
|
1458
1486
|
}
|
|
1459
1487
|
}
|
|
1460
1488
|
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export {};
|