decap-cms-lib-util 3.8.1 → 3.8.3
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/dist/decap-cms-lib-util.js.map +1 -1
- package/dist/esm/index.js +3 -1
- package/dist/esm/notesFormat.js +145 -0
- package/dist/esm/notesPolling.js +424 -0
- package/package.json +18 -16
- package/LICENSE +0 -22
package/dist/esm/index.js
CHANGED
|
@@ -7,6 +7,8 @@ import { isAbsolutePath, basename, fileExtensionWithSeparator, fileExtension } f
|
|
|
7
7
|
import { onlySuccessfulPromises, flowAsync, then } from './promise';
|
|
8
8
|
import unsentRequest from './unsentRequest';
|
|
9
9
|
import { filterByExtension, getAllResponses, parseLinkHeader, parseResponse, responseParser, getPathDepth } from './backendUtil';
|
|
10
|
+
import { NotesPollingManager } from './notesPolling';
|
|
11
|
+
import { formatNoteBody, parseNoteBody, commentToNote, commentsToNotes, markOwnNotes } from './notesFormat';
|
|
10
12
|
import loadScript from './loadScript';
|
|
11
13
|
import getBlobSHA from './getBlobSHA';
|
|
12
14
|
import { asyncLock } from './asyncLock';
|
|
@@ -68,4 +70,4 @@ export const DecapCmsLibUtil = {
|
|
|
68
70
|
AccessTokenError,
|
|
69
71
|
throwOnConflictingBranches
|
|
70
72
|
};
|
|
71
|
-
export { APIError, Cursor, CURSOR_COMPATIBILITY_SYMBOL, EditorialWorkflowError, EDITORIAL_WORKFLOW_ERROR, localForage, basename, fileExtensionWithSeparator, fileExtension, onlySuccessfulPromises, flowAsync, then, unsentRequest, filterByExtension, parseLinkHeader, getAllResponses, parseResponse, responseParser, loadScript, getBlobSHA, asyncLock, isAbsolutePath, getPathDepth, entriesByFiles, entriesByFolder, unpublishedEntries, getMediaDisplayURL, getMediaAsBlob, readFile, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, isCMSLabel, labelToStatus, statusToLabel, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, isPreviewContext, getPreviewStatus, runWithLock, PreviewState, parseContentKey, createPointerFile, getLargeMediaFilteredMediaFiles, getLargeMediaPatternsFromGitAttributesFile, parsePointerFile, getPointerFileForMediaFileObj, branchFromContentKey, contentKeyFromBranch, blobToFileObj, requestWithBackoff, getDefaultBranchName, allEntriesByFolder, AccessTokenError, throwOnConflictingBranches };
|
|
73
|
+
export { NotesPollingManager, formatNoteBody, parseNoteBody, commentToNote, commentsToNotes, markOwnNotes, APIError, Cursor, CURSOR_COMPATIBILITY_SYMBOL, EditorialWorkflowError, EDITORIAL_WORKFLOW_ERROR, localForage, basename, fileExtensionWithSeparator, fileExtension, onlySuccessfulPromises, flowAsync, then, unsentRequest, filterByExtension, parseLinkHeader, getAllResponses, parseResponse, responseParser, loadScript, getBlobSHA, asyncLock, isAbsolutePath, getPathDepth, entriesByFiles, entriesByFolder, unpublishedEntries, getMediaDisplayURL, getMediaAsBlob, readFile, readFileMetadata, CMS_BRANCH_PREFIX, generateContentKey, isCMSLabel, labelToStatus, statusToLabel, DEFAULT_PR_BODY, MERGE_COMMIT_MESSAGE, isPreviewContext, getPreviewStatus, runWithLock, PreviewState, parseContentKey, createPointerFile, getLargeMediaFilteredMediaFiles, getLargeMediaPatternsFromGitAttributesFile, parsePointerFile, getPointerFileForMediaFileObj, branchFromContentKey, contentKeyFromBranch, blobToFileObj, requestWithBackoff, getDefaultBranchName, allEntriesByFolder, AccessTokenError, throwOnConflictingBranches };
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How a note is encoded inside a comment on the host
|
|
3
|
+
*
|
|
4
|
+
* A note is an ordinary comment with a leading HTML comment carrying
|
|
5
|
+
* whether the note is resolved and who wrote it - which can differ from
|
|
6
|
+
* the account that posted it.
|
|
7
|
+
* Hosts render HTML comments invisibly, so the marker stays out of the way
|
|
8
|
+
* when the thread is read on the host's own site.
|
|
9
|
+
*
|
|
10
|
+
* Encoding is identical across backends
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const MARKER_PREFIX = '<!-- DecapCMS Note ';
|
|
14
|
+
const MARKER_SUFFIX = ' -->';
|
|
15
|
+
const NOTE_PATTERN = /^<!-- DecapCMS Note (\{[\s\S]*?\}) -->\n?([\s\S]*)$/;
|
|
16
|
+
const LEGACY_NOTE_PATTERN = /^<!-- DecapCMS Note - Status: (RESOLVED|OPEN) -->\n?([\s\S]*)$/;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A JSON string may contain `-->`, which would close the HTML comment early.
|
|
20
|
+
* Escaping every hyphen that precedes another removes every `--`, so `-->`
|
|
21
|
+
* cannot survive; `JSON.parse` decodes `\u002d` back, so it stays lossless
|
|
22
|
+
* and a lone hyphen (`Jean-Luc`) stays readable.
|
|
23
|
+
*
|
|
24
|
+
* Replacing `--` instead is not enough - a run of five hyphens leaves one
|
|
25
|
+
* behind at the seam.
|
|
26
|
+
*/
|
|
27
|
+
function encode(payload) {
|
|
28
|
+
return JSON.stringify(payload).replace(/-(?=-)/g, '\\u002d');
|
|
29
|
+
}
|
|
30
|
+
export function formatNoteBody(note) {
|
|
31
|
+
// Both or neither: a name with no id to compare against buys nothing, and a
|
|
32
|
+
// backend whose poster IS the editor records neither.
|
|
33
|
+
const identity = note.authorId && note.author ? {
|
|
34
|
+
author: note.author,
|
|
35
|
+
authorId: note.authorId
|
|
36
|
+
} : {};
|
|
37
|
+
const marker = encode({
|
|
38
|
+
resolved: note.resolved,
|
|
39
|
+
...identity
|
|
40
|
+
});
|
|
41
|
+
return `${MARKER_PREFIX}${marker}${MARKER_SUFFIX}\n${note.content}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The marker is hand-editable on the host, so nothing in it is trusted to be
|
|
46
|
+
* the type it should be. A non-string author reaching `Note` crashes the pane
|
|
47
|
+
* outright - the avatar initials call `.split()` on it - so anything that is
|
|
48
|
+
* not a usable string is treated as no recorded author, which falls back to
|
|
49
|
+
* the account that posted the comment.
|
|
50
|
+
*/
|
|
51
|
+
function asString(value) {
|
|
52
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
53
|
+
}
|
|
54
|
+
export function parseNoteBody(body) {
|
|
55
|
+
const match = body.match(NOTE_PATTERN);
|
|
56
|
+
if (!match) {
|
|
57
|
+
const legacy = body.match(LEGACY_NOTE_PATTERN);
|
|
58
|
+
if (legacy) {
|
|
59
|
+
return {
|
|
60
|
+
content: legacy[2].trim(),
|
|
61
|
+
resolved: legacy[1] === 'RESOLVED'
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// A comment typed straight into the thread on the host. It is still a note,
|
|
66
|
+
// just an unresolved one with no recorded author.
|
|
67
|
+
return {
|
|
68
|
+
content: body.trim(),
|
|
69
|
+
resolved: false
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
let payload;
|
|
73
|
+
try {
|
|
74
|
+
payload = JSON.parse(match[1]);
|
|
75
|
+
} catch {
|
|
76
|
+
// Someone edited the marker by hand into something unparseable. Treat the
|
|
77
|
+
// whole comment as content rather than dropping the note.
|
|
78
|
+
return {
|
|
79
|
+
content: body.trim(),
|
|
80
|
+
resolved: false
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
const author = asString(payload.author);
|
|
84
|
+
const authorId = asString(payload.authorId);
|
|
85
|
+
|
|
86
|
+
// Both or neither, as formatNoteBody writes them.
|
|
87
|
+
const identity = author && authorId ? {
|
|
88
|
+
author,
|
|
89
|
+
authorId
|
|
90
|
+
} : {};
|
|
91
|
+
return {
|
|
92
|
+
content: match[2].trim(),
|
|
93
|
+
resolved: payload.resolved === true,
|
|
94
|
+
author: identity.author,
|
|
95
|
+
authorId: identity.authorId
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
export function commentToNote(comment) {
|
|
99
|
+
if (!comment || !comment.body) {
|
|
100
|
+
throw new Error('Invalid comment structure');
|
|
101
|
+
}
|
|
102
|
+
const {
|
|
103
|
+
content,
|
|
104
|
+
resolved,
|
|
105
|
+
author,
|
|
106
|
+
authorId
|
|
107
|
+
} = parseNoteBody(comment.body);
|
|
108
|
+
if (!content) {
|
|
109
|
+
throw new Error('Empty note content');
|
|
110
|
+
}
|
|
111
|
+
return {
|
|
112
|
+
id: comment.id.toString(),
|
|
113
|
+
// Falls back to the account that posted, for a comment typed on the host
|
|
114
|
+
// and for any note with no recorded author.
|
|
115
|
+
author: author || comment.user?.login || 'Unknown',
|
|
116
|
+
authorId,
|
|
117
|
+
// Only when the posting account IS the note's author — a recorded author
|
|
118
|
+
// means someone posted on their behalf, and its avatar would mislabel the
|
|
119
|
+
// note. The pane shows initials instead.
|
|
120
|
+
avatarUrl: authorId ? undefined : comment.user?.avatar_url || undefined,
|
|
121
|
+
timestamp: comment.created_at,
|
|
122
|
+
content,
|
|
123
|
+
resolved,
|
|
124
|
+
entrySlug: ''
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
export function commentsToNotes(comments, issueUrl, toNote = commentToNote) {
|
|
128
|
+
return comments.reduce((notes, comment) => {
|
|
129
|
+
try {
|
|
130
|
+
notes.push({
|
|
131
|
+
...toNote(comment),
|
|
132
|
+
issueUrl
|
|
133
|
+
});
|
|
134
|
+
} catch (error) {
|
|
135
|
+
// Not a note; skip it
|
|
136
|
+
}
|
|
137
|
+
return notes;
|
|
138
|
+
}, []);
|
|
139
|
+
}
|
|
140
|
+
export function markOwnNotes(notes, identity) {
|
|
141
|
+
return notes.map(note => ({
|
|
142
|
+
...note,
|
|
143
|
+
isOwn: note.authorId ? note.authorId === identity.authorId : note.author === identity.author
|
|
144
|
+
}));
|
|
145
|
+
}
|
|
@@ -0,0 +1,424 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notes polling
|
|
3
|
+
*
|
|
4
|
+
* Watches the thread an entry's notes live in and reports what changed, so
|
|
5
|
+
* notes another editor adds appear without a reload. Where the host supports
|
|
6
|
+
* conditional requests the poll is an ETag round trip that returns 304 and
|
|
7
|
+
* costs no rate limit; where it does not, the manager still only reports a
|
|
8
|
+
* change when the thread's contents actually differ, so a host that always
|
|
9
|
+
* answers 200 is slower, not wrong.
|
|
10
|
+
*
|
|
11
|
+
* Not specific to one host: it talks to `NotesPollingAPI` below,
|
|
12
|
+
* which any backend with a comment thread per entry can satisfy.
|
|
13
|
+
*
|
|
14
|
+
* @module notesPolling
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { commentsToNotes } from './notesFormat';
|
|
18
|
+
function noop() {
|
|
19
|
+
/* nothing to unwatch */
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
// Redux action types
|
|
23
|
+
export const NOTES_POLLING_START = 'NOTES_POLLING_START';
|
|
24
|
+
export const NOTES_POLLING_STOP = 'NOTES_POLLING_STOP';
|
|
25
|
+
export const NOTES_POLLING_UPDATE = 'NOTES_POLLING_UPDATE';
|
|
26
|
+
export const NOTES_CHANGE_DETECTED = 'NOTES_CHANGE_DETECTED';
|
|
27
|
+
export class NotesPollingManager {
|
|
28
|
+
currentWatch = null;
|
|
29
|
+
currentIssueKey = null;
|
|
30
|
+
pollingInterval = 15000;
|
|
31
|
+
intervalId = null;
|
|
32
|
+
isDocumentVisible = true;
|
|
33
|
+
isPolling = false;
|
|
34
|
+
pendingRetryTimeout = null;
|
|
35
|
+
cancelPendingRetry = null;
|
|
36
|
+
pendingIssueKey = null;
|
|
37
|
+
watchGeneration = 0;
|
|
38
|
+
constructor(api, pollingInterval = 15000) {
|
|
39
|
+
this.api = api;
|
|
40
|
+
this.pollingInterval = pollingInterval;
|
|
41
|
+
this.setupVisibilityListener();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Setup Page Visibility API listener
|
|
46
|
+
* Pauses polling when tab is hidden
|
|
47
|
+
*/
|
|
48
|
+
setupVisibilityListener() {
|
|
49
|
+
if (typeof document !== 'undefined') {
|
|
50
|
+
document.addEventListener('visibilitychange', this.handleVisibilityChange);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
handleVisibilityChange = () => {
|
|
54
|
+
this.isDocumentVisible = !document.hidden;
|
|
55
|
+
if (this.isDocumentVisible) {
|
|
56
|
+
this.startPolling();
|
|
57
|
+
this.checkAllIssuesNow();
|
|
58
|
+
} else {
|
|
59
|
+
this.stopPolling();
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Start watching an issue for changes
|
|
65
|
+
* This will automatically stop watching any previously watched issue
|
|
66
|
+
*
|
|
67
|
+
* @param issueNumber - GitHub issue number
|
|
68
|
+
* @param collection - Collection name
|
|
69
|
+
* @param slug - Entry slug
|
|
70
|
+
* @returns Function to stop watching
|
|
71
|
+
*/
|
|
72
|
+
async watchIssue(issueNumber, collection, slug, callbacks, initialState = null) {
|
|
73
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
74
|
+
|
|
75
|
+
// STOP ANY EXISTING WATCH FIRST
|
|
76
|
+
this.stopCurrentWatch();
|
|
77
|
+
const generation = this.watchGeneration;
|
|
78
|
+
|
|
79
|
+
// Get initial state if not provided
|
|
80
|
+
if (!initialState) {
|
|
81
|
+
try {
|
|
82
|
+
initialState = await this.api.getIssueState(issueNumber);
|
|
83
|
+
} catch (error) {
|
|
84
|
+
console.error('[DecapNotes Polling] Failed to get initial state:', error);
|
|
85
|
+
}
|
|
86
|
+
if (generation !== this.watchGeneration) {
|
|
87
|
+
return noop;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
const watch = {
|
|
91
|
+
issueNumber,
|
|
92
|
+
collection,
|
|
93
|
+
slug,
|
|
94
|
+
etag: null,
|
|
95
|
+
lastState: initialState,
|
|
96
|
+
onUpdate: callbacks.onUpdate,
|
|
97
|
+
onChange: callbacks.onChange,
|
|
98
|
+
prepareNotes: callbacks.prepareNotes,
|
|
99
|
+
retryCount: 0,
|
|
100
|
+
maxRetries: 5
|
|
101
|
+
};
|
|
102
|
+
this.currentWatch = watch;
|
|
103
|
+
this.currentIssueKey = issueKey;
|
|
104
|
+
|
|
105
|
+
// Start polling if not already running
|
|
106
|
+
if (!this.intervalId && this.isDocumentVisible) {
|
|
107
|
+
this.startPolling();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Do an immediate check
|
|
111
|
+
this.checkCurrentIssue();
|
|
112
|
+
|
|
113
|
+
// Return unwatch function
|
|
114
|
+
return () => {
|
|
115
|
+
if (this.currentWatch === watch) {
|
|
116
|
+
this.stopCurrentWatch();
|
|
117
|
+
}
|
|
118
|
+
};
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Watch issue with retry logic for newly created issues
|
|
123
|
+
*/
|
|
124
|
+
async watchIssueWithRetry(collection, slug, callbacks, maxRetries = 5, retryDelay = 2000) {
|
|
125
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
126
|
+
|
|
127
|
+
// STOP ANY EXISTING WATCH FIRST
|
|
128
|
+
this.stopCurrentWatch();
|
|
129
|
+
const generation = this.watchGeneration;
|
|
130
|
+
this.pendingIssueKey = issueKey;
|
|
131
|
+
const isSuperseded = () => generation !== this.watchGeneration;
|
|
132
|
+
const attemptWatch = async attempt => {
|
|
133
|
+
let lookupError;
|
|
134
|
+
let issue = null;
|
|
135
|
+
try {
|
|
136
|
+
issue = await this.api.findEntryIssue(collection, slug);
|
|
137
|
+
} catch (error) {
|
|
138
|
+
console.error(`[DecapNotes Polling] Error finding issue for ${issueKey}:`, error);
|
|
139
|
+
lookupError = error;
|
|
140
|
+
}
|
|
141
|
+
if (isSuperseded()) {
|
|
142
|
+
return noop;
|
|
143
|
+
}
|
|
144
|
+
if (issue) {
|
|
145
|
+
this.pendingIssueKey = null;
|
|
146
|
+
return this.watchIssue(issue.number, collection, slug, callbacks);
|
|
147
|
+
}
|
|
148
|
+
if (attempt < maxRetries) {
|
|
149
|
+
const elapsed = await this.waitForRetry(retryDelay);
|
|
150
|
+
return elapsed && !isSuperseded() ? attemptWatch(attempt + 1) : noop;
|
|
151
|
+
}
|
|
152
|
+
this.pendingIssueKey = null;
|
|
153
|
+
if (lookupError) {
|
|
154
|
+
throw lookupError;
|
|
155
|
+
}
|
|
156
|
+
console.log(`[DecapNotes Polling] No issue found for ${issueKey} after ${maxRetries} attempts. This is expected if there are no notes for this entry yet.`);
|
|
157
|
+
return noop;
|
|
158
|
+
};
|
|
159
|
+
return attemptWatch(1);
|
|
160
|
+
}
|
|
161
|
+
waitForRetry(delay) {
|
|
162
|
+
return new Promise(resolve => {
|
|
163
|
+
this.cancelPendingRetry = () => resolve(false);
|
|
164
|
+
this.pendingRetryTimeout = setTimeout(() => {
|
|
165
|
+
this.pendingRetryTimeout = null;
|
|
166
|
+
this.cancelPendingRetry = null;
|
|
167
|
+
resolve(true);
|
|
168
|
+
}, delay);
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
clearPendingRetry() {
|
|
172
|
+
if (this.pendingRetryTimeout) {
|
|
173
|
+
clearTimeout(this.pendingRetryTimeout);
|
|
174
|
+
this.pendingRetryTimeout = null;
|
|
175
|
+
}
|
|
176
|
+
this.cancelPendingRetry?.();
|
|
177
|
+
this.cancelPendingRetry = null;
|
|
178
|
+
this.pendingIssueKey = null;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Stop watching an entry, including a pending retry
|
|
183
|
+
*/
|
|
184
|
+
stopWatching(collection, slug) {
|
|
185
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
186
|
+
if (this.currentIssueKey === issueKey || this.pendingIssueKey === issueKey) {
|
|
187
|
+
this.stopCurrentWatch();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Stop watching the current issue - complete cleanup
|
|
193
|
+
*/
|
|
194
|
+
stopCurrentWatch() {
|
|
195
|
+
this.watchGeneration += 1;
|
|
196
|
+
this.clearPendingRetry();
|
|
197
|
+
if (!this.currentWatch) {
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Clear current watch
|
|
202
|
+
this.currentWatch = null;
|
|
203
|
+
this.currentIssueKey = null;
|
|
204
|
+
|
|
205
|
+
// Stop polling since there's nothing to watch
|
|
206
|
+
this.stopPolling();
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Start the polling loop
|
|
211
|
+
*/
|
|
212
|
+
startPolling() {
|
|
213
|
+
if (this.intervalId || !this.isDocumentVisible || !this.currentWatch) return;
|
|
214
|
+
console.log(`[DecapNotes Polling] Starting polling loop (${this.pollingInterval}ms interval) for ${this.currentIssueKey}`);
|
|
215
|
+
this.intervalId = setInterval(() => {
|
|
216
|
+
this.pollAllIssues();
|
|
217
|
+
}, this.pollingInterval);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Stop the polling loop
|
|
222
|
+
*/
|
|
223
|
+
stopPolling() {
|
|
224
|
+
if (this.intervalId) {
|
|
225
|
+
console.log('[DecapNotes Polling] Stopping polling loop');
|
|
226
|
+
clearInterval(this.intervalId);
|
|
227
|
+
this.intervalId = null;
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Poll current watched issue
|
|
233
|
+
*/
|
|
234
|
+
async pollAllIssues() {
|
|
235
|
+
await this.checkCurrentIssue();
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Check current issue for changes using ETag
|
|
240
|
+
*/
|
|
241
|
+
async checkCurrentIssue() {
|
|
242
|
+
if (!this.currentWatch) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (this.isPolling) {
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
this.isPolling = true;
|
|
249
|
+
try {
|
|
250
|
+
const watch = this.currentWatch;
|
|
251
|
+
const response = await this.api.getIssueWithETag(watch.issueNumber, watch.etag);
|
|
252
|
+
if (response.status === 304) {
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
if (this.currentWatch !== watch) {
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
if (response.status === 200) {
|
|
259
|
+
const newState = response.data;
|
|
260
|
+
|
|
261
|
+
// Detect specific changes
|
|
262
|
+
const changes = this.detectChanges(watch.lastState, newState);
|
|
263
|
+
if (changes.length > 0) {
|
|
264
|
+
// Convert comments to notes
|
|
265
|
+
let newNotes = commentsToNotes(newState.comments, newState.html_url, this.api.parseCommentToNote && (comment => this.api.parseCommentToNote(comment)));
|
|
266
|
+
if (watch.prepareNotes) {
|
|
267
|
+
newNotes = await watch.prepareNotes(newNotes);
|
|
268
|
+
if (this.currentWatch !== watch) {
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (watch.onUpdate) {
|
|
273
|
+
watch.onUpdate(newNotes, changes);
|
|
274
|
+
}
|
|
275
|
+
if (watch.onChange) {
|
|
276
|
+
changes.forEach(change => {
|
|
277
|
+
watch.onChange(change);
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// Update ETag and stored state
|
|
283
|
+
watch.etag = response.etag || null;
|
|
284
|
+
watch.lastState = newState;
|
|
285
|
+
}
|
|
286
|
+
} catch (error) {
|
|
287
|
+
console.error(`[DecapNotes Polling] Error checking ${this.currentIssueKey}:`, error);
|
|
288
|
+
} finally {
|
|
289
|
+
this.isPolling = false;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
/**
|
|
294
|
+
* Immediately check current issue
|
|
295
|
+
*/
|
|
296
|
+
async checkAllIssuesNow() {
|
|
297
|
+
await this.checkCurrentIssue();
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* Manually trigger a check - only works if this is the current entry
|
|
302
|
+
*/
|
|
303
|
+
async checkIssueNow(collection, slug) {
|
|
304
|
+
const issueKey = this.getIssueKey(collection, slug);
|
|
305
|
+
if (this.currentIssueKey !== issueKey) {
|
|
306
|
+
console.warn(`[DecapNotes Polling] Cannot check ${issueKey} - currently watching ${this.currentIssueKey}`);
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
await this.checkCurrentIssue();
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Detect what changed between two states
|
|
314
|
+
*/
|
|
315
|
+
detectChanges(previous, current) {
|
|
316
|
+
if (!previous) {
|
|
317
|
+
return [];
|
|
318
|
+
}
|
|
319
|
+
const changes = [];
|
|
320
|
+
|
|
321
|
+
// New comments
|
|
322
|
+
const newComments = current.comments.filter(comment => !previous.comments.some(prev => prev.id === comment.id));
|
|
323
|
+
newComments.forEach(comment => {
|
|
324
|
+
changes.push({
|
|
325
|
+
type: 'comment_added',
|
|
326
|
+
data: comment,
|
|
327
|
+
timestamp: comment.created_at
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// Updated comments
|
|
332
|
+
current.comments.forEach(comment => {
|
|
333
|
+
const prevComment = previous.comments.find(prev => prev.id === comment.id);
|
|
334
|
+
if (prevComment && prevComment.updated_at !== comment.updated_at) {
|
|
335
|
+
changes.push({
|
|
336
|
+
type: 'comment_updated',
|
|
337
|
+
data: comment,
|
|
338
|
+
previousData: prevComment,
|
|
339
|
+
timestamp: comment.updated_at
|
|
340
|
+
});
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
// Deleted comments
|
|
345
|
+
const deletedComments = previous.comments.filter(prevComment => !current.comments.some(comment => comment.id === prevComment.id));
|
|
346
|
+
deletedComments.forEach(comment => {
|
|
347
|
+
changes.push({
|
|
348
|
+
type: 'comment_deleted',
|
|
349
|
+
data: comment,
|
|
350
|
+
timestamp: new Date().toISOString()
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
// Issue state changed
|
|
355
|
+
if (previous.state !== current.state) {
|
|
356
|
+
changes.push({
|
|
357
|
+
type: 'issue_state_changed',
|
|
358
|
+
data: {
|
|
359
|
+
from: previous.state,
|
|
360
|
+
to: current.state
|
|
361
|
+
},
|
|
362
|
+
timestamp: current.updated_at
|
|
363
|
+
});
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
// Labels changed
|
|
367
|
+
if (this.hasLabelsChanged(previous.labels, current.labels)) {
|
|
368
|
+
changes.push({
|
|
369
|
+
type: 'issue_labels_changed',
|
|
370
|
+
data: {
|
|
371
|
+
from: previous.labels,
|
|
372
|
+
to: current.labels
|
|
373
|
+
},
|
|
374
|
+
timestamp: current.updated_at
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
return changes;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Check if labels changed
|
|
382
|
+
*/
|
|
383
|
+
hasLabelsChanged(previous, current) {
|
|
384
|
+
if (previous.length !== current.length) return true;
|
|
385
|
+
const prevNames = previous.map(l => l.name).sort();
|
|
386
|
+
const currNames = current.map(l => l.name).sort();
|
|
387
|
+
return prevNames.join(',') !== currNames.join(',');
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Get issue key for storage
|
|
392
|
+
*/
|
|
393
|
+
getIssueKey(collection, slug) {
|
|
394
|
+
return `${collection}/${slug}`;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Get polling status
|
|
399
|
+
*/
|
|
400
|
+
getStatus() {
|
|
401
|
+
return {
|
|
402
|
+
isPolling: this.intervalId !== null,
|
|
403
|
+
currentWatch: this.currentIssueKey,
|
|
404
|
+
watchedCount: this.currentWatch ? 1 : 0,
|
|
405
|
+
pollingInterval: this.pollingInterval,
|
|
406
|
+
isDocumentVisible: this.isDocumentVisible,
|
|
407
|
+
hasPendingRetry: this.pendingRetryTimeout !== null
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
/**
|
|
412
|
+
* Clean up - stop all polling
|
|
413
|
+
*/
|
|
414
|
+
destroy() {
|
|
415
|
+
console.log('[DecapNotes Polling] Destroying polling manager');
|
|
416
|
+
|
|
417
|
+
// Stop current watch
|
|
418
|
+
this.stopCurrentWatch();
|
|
419
|
+
if (typeof document !== 'undefined') {
|
|
420
|
+
document.removeEventListener('visibilitychange', this.handleVisibilityChange);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
export default NotesPollingManager;
|
package/package.json
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "decap-cms-lib-util",
|
|
3
3
|
"description": "Shared utilities for Decap CMS.",
|
|
4
|
-
"version": "3.8.
|
|
5
|
-
"repository":
|
|
4
|
+
"version": "3.8.3",
|
|
5
|
+
"repository": {
|
|
6
|
+
"type": "git",
|
|
7
|
+
"url": "https://github.com/decaporg/decap-cms/tree/main/packages/decap-cms-lib-util"
|
|
8
|
+
},
|
|
6
9
|
"bugs": "https://github.com/decaporg/decap-cms/issues",
|
|
7
10
|
"module": "dist/esm/index.js",
|
|
8
11
|
"main": "dist/decap-cms-lib-util.js",
|
|
@@ -14,23 +17,22 @@
|
|
|
14
17
|
"decap-cms"
|
|
15
18
|
],
|
|
16
19
|
"sideEffects": false,
|
|
17
|
-
"scripts": {
|
|
18
|
-
"develop": "pnpm run build:esm --watch",
|
|
19
|
-
"build": "cross-env NODE_ENV=production webpack",
|
|
20
|
-
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
|
21
|
-
},
|
|
22
20
|
"dependencies": {
|
|
23
|
-
"js-sha256": "
|
|
24
|
-
"localforage": "
|
|
25
|
-
"semaphore": "
|
|
21
|
+
"js-sha256": "^0.9.0",
|
|
22
|
+
"localforage": "^1.7.3",
|
|
23
|
+
"semaphore": "^1.1.0"
|
|
26
24
|
},
|
|
27
25
|
"peerDependencies": {
|
|
28
|
-
"common-tags": "
|
|
29
|
-
"immutable": "
|
|
30
|
-
"lodash": "
|
|
26
|
+
"common-tags": "1.8.2",
|
|
27
|
+
"immutable": "^4.3.9",
|
|
28
|
+
"lodash": "^4.17.11"
|
|
31
29
|
},
|
|
32
30
|
"devDependencies": {
|
|
33
|
-
"common-tags": "
|
|
31
|
+
"common-tags": "1.8.2"
|
|
34
32
|
},
|
|
35
|
-
"
|
|
36
|
-
|
|
33
|
+
"scripts": {
|
|
34
|
+
"develop": "pnpm run build:esm --watch",
|
|
35
|
+
"build": "cross-env NODE_ENV=production webpack",
|
|
36
|
+
"build:esm": "cross-env NODE_ENV=esm babel src --out-dir dist/esm --ignore \"**/__tests__\" --root-mode upward --extensions \".js,.jsx,.ts,.tsx\""
|
|
37
|
+
}
|
|
38
|
+
}
|
package/LICENSE
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
Copyright (c) 2016 Netlify <decap@p-m.si>
|
|
2
|
-
|
|
3
|
-
MIT License
|
|
4
|
-
|
|
5
|
-
Permission is hereby granted, free of charge, to any person obtaining
|
|
6
|
-
a copy of this software and associated documentation files (the
|
|
7
|
-
"Software"), to deal in the Software without restriction, including
|
|
8
|
-
without limitation the rights to use, copy, modify, merge, publish,
|
|
9
|
-
distribute, sublicense, and/or sell copies of the Software, and to
|
|
10
|
-
permit persons to whom the Software is furnished to do so, subject to
|
|
11
|
-
the following conditions:
|
|
12
|
-
|
|
13
|
-
The above copyright notice and this permission notice shall be
|
|
14
|
-
included in all copies or substantial portions of the Software.
|
|
15
|
-
|
|
16
|
-
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
|
|
17
|
-
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
|
|
18
|
-
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
|
|
19
|
-
NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE
|
|
20
|
-
LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
|
21
|
-
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
|
|
22
|
-
WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|