mirror of
https://github.com/yu-i-i/overleaf-cep.git
synced 2026-11-12 21:23:45 +01:00
Compare commits
106
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f32a52b253 | ||
|
|
26e7bcbf69 | ||
|
|
92ee773c32 | ||
|
|
89883b0efe | ||
|
|
53d437dca2 | ||
|
|
e1dd90fa8d | ||
|
|
120def39cc | ||
|
|
2b737db3c9 | ||
|
|
526699bc4e | ||
|
|
a223f4f290 | ||
|
|
1a264f7562 | ||
|
|
a2a455517e | ||
|
|
5692338d4b | ||
|
|
b46501f874 | ||
|
|
377732a832 | ||
|
|
362fc56949 | ||
|
|
9d00984b97 | ||
|
|
14d0300fc5 | ||
|
|
fdb7578742 | ||
|
|
c250e705b8 | ||
|
|
eb53dd3b22 | ||
|
|
860b999ce2 | ||
|
|
bb0804c6eb | ||
|
|
7b407d2523 | ||
|
|
114911957e | ||
|
|
0b8bcca8a0 | ||
|
|
e576fcb794 | ||
|
|
9808a1d290 | ||
|
|
523e5c3fcf | ||
|
|
dad69464dd | ||
|
|
00895bd201 | ||
|
|
07b903b0b4 | ||
|
|
73162171ee | ||
|
|
522704855f | ||
|
|
d22c71c7a6 | ||
|
|
d7378b42b7 | ||
|
|
2d8a963e79 | ||
|
|
de6e491b06 | ||
|
|
d4e11a1f80 | ||
|
|
6f5acbd11b | ||
|
|
103adeb820 | ||
|
|
b1db82ab0a | ||
|
|
5bc427623d | ||
|
|
f46927c3c3 | ||
|
|
22bee12e6b | ||
|
|
e3192dee6d | ||
|
|
5c7e85fed1 | ||
|
|
7a0dc2364f | ||
|
|
4f8c0fb0a1 | ||
|
|
ed195212e0 | ||
|
|
f6cfd0b71d | ||
|
|
c35170ce61 | ||
|
|
0c499ebc5b | ||
|
|
c53c51a223 | ||
|
|
370c6661f4 | ||
|
|
fea5749d0e | ||
|
|
198bbb6fca | ||
|
|
89b2b1910e | ||
|
|
e3a976d2c2 | ||
|
|
ab3660b121 | ||
|
|
9bf4379f84 | ||
|
|
05e929082a | ||
|
|
287653cfea | ||
|
|
3b3734756a | ||
|
|
f41c73ee3d | ||
|
|
4fd4e39199 | ||
|
|
b1609835a4 | ||
|
|
984a641a93 | ||
|
|
0437f238de | ||
|
|
eb94a0e3af | ||
|
|
03206a00bf | ||
|
|
9cabcdee2e | ||
|
|
d668377045 | ||
|
|
9088a32628 | ||
|
|
19ac7b5e22 | ||
|
|
b57053a3fb | ||
|
|
c5b3789630 | ||
|
|
0e0b60ab72 | ||
|
|
3edaf8e4a8 | ||
|
|
2ad1147cbc | ||
|
|
4bdeb2987d | ||
|
|
7194be158b | ||
|
|
96e7bec2c4 | ||
|
|
a06f584898 | ||
|
|
de34fbced5 | ||
|
|
c8d3965da8 | ||
|
|
b663e003bf | ||
|
|
9443effae8 | ||
|
|
b4a15d0803 | ||
|
|
1d5e5f5950 | ||
|
|
2fbffeb206 | ||
|
|
194cf2d832 | ||
|
|
a8b4458a92 | ||
|
|
680d20ba5f | ||
|
|
f4dae8b41c | ||
|
|
b7a07d2725 | ||
|
|
cb6dcc78f8 | ||
|
|
cbebcd99e2 | ||
|
|
744c1dcbff | ||
|
|
c803ffd00a | ||
|
|
34d7c15ddc | ||
|
|
c2c27d3032 | ||
|
|
37a6c5286a | ||
|
|
ec1f16f716 | ||
|
|
0d80609e2c | ||
|
|
033ebdeab6 |
@@ -4,6 +4,7 @@ about: Report a bug
|
||||
title: ''
|
||||
labels: type:bug
|
||||
assignees: ''
|
||||
|
||||
---
|
||||
|
||||
<!--
|
||||
@@ -13,10 +14,11 @@ Note: This form should only be used to report bugs in the
|
||||
|
||||
-->
|
||||
|
||||
|
||||
|
||||
<!-- BUG REPORT TEMPLATE -->
|
||||
|
||||
## Steps to Reproduce
|
||||
|
||||
<!-- Describe the steps leading up to when / where you found the bug. -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
@@ -25,28 +27,23 @@ Note: This form should only be used to report bugs in the
|
||||
3.
|
||||
|
||||
## Expected Behaviour
|
||||
|
||||
<!-- What should have happened when you completed the steps above? -->
|
||||
|
||||
## Observed Behaviour
|
||||
|
||||
<!-- What actually happened when you completed the steps above? -->
|
||||
<!-- Screenshots may be helpful here. -->
|
||||
|
||||
## Context
|
||||
|
||||
<!-- How has this issue affected you? What were you trying to accomplish? -->
|
||||
|
||||
## Technical Info
|
||||
|
||||
<!-- Provide any technical details that may be applicable (or N/A if not applicable). -->
|
||||
|
||||
- URL:
|
||||
- Browser Name and version:
|
||||
- Operating System and version (desktop or mobile):
|
||||
- Signed in as:
|
||||
- Project and/or file:
|
||||
* URL:
|
||||
* Browser Name and version:
|
||||
* Operating System and version (desktop or mobile):
|
||||
* Signed in as:
|
||||
* Project and/or file:
|
||||
|
||||
## Analysis
|
||||
|
||||
<!--- Optionally, document investigation of / suggest a fix for the bug, e.g. 'comes from this line / commit' -->
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
## Description
|
||||
|
||||
<!-- Goal of the pull request -->
|
||||
|
||||
## Related issues / Pull Requests
|
||||
|
||||
## Related issues / Pull Requests
|
||||
<!-- Fixes #xyz, Contributes to #xyz, Related to #xyz-->
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
diff --git a/dist/index.mjs b/dist/index.mjs
|
||||
index b781835b37a1262510bdd66d8d3b399a076e9d68..312c314186d85bb3bc2ab2620e99bca259ef7167 100644
|
||||
--- a/dist/index.mjs
|
||||
+++ b/dist/index.mjs
|
||||
@@ -44,10 +44,9 @@ import { z as z2 } from "zod/v4";
|
||||
|
||||
// src/tool/types.ts
|
||||
import { z } from "zod/v4";
|
||||
-var LATEST_PROTOCOL_VERSION = "2025-11-25";
|
||||
+var LATEST_PROTOCOL_VERSION = "2025-06-18";
|
||||
var SUPPORTED_PROTOCOL_VERSIONS = [
|
||||
LATEST_PROTOCOL_VERSION,
|
||||
- "2025-06-18",
|
||||
"2025-03-26",
|
||||
"2024-11-05"
|
||||
];
|
||||
@@ -1,831 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 39215ae..b44cb76 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -187,16 +187,23 @@ Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-function insertCompletionText(state$1, text, from, to) {
|
||||
+function insertCompletionText(state$1, text, from, to, extend) {
|
||||
let { main } = state$1.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
return Object.assign(Object.assign({}, state$1.changeByRange(range => {
|
||||
if (range != main && from != to &&
|
||||
state$1.sliceDoc(range.from + fromOff, range.from + toOff) != state$1.sliceDoc(from, to))
|
||||
return { range };
|
||||
- let lines = state$1.toText(text);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: to == main.from ? range.to : range.from + toOff,
|
||||
+ insert: text instanceof state.Text ? text : state$1.toText(text),
|
||||
+ };
|
||||
+ if (extend) {
|
||||
+ extend(state$1, change);
|
||||
+ }
|
||||
return {
|
||||
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
|
||||
- range: state.EditorSelection.cursor(range.from + fromOff + lines.length)
|
||||
+ changes: change,
|
||||
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
|
||||
};
|
||||
})), { scrollIntoView: true, userEvent: "input.complete" });
|
||||
}
|
||||
@@ -389,7 +396,9 @@ const completionConfig = state.Facet.define({
|
||||
filterStrict: false,
|
||||
compareCompletions: (a, b) => a.label.localeCompare(b.label),
|
||||
interactionDelay: 75,
|
||||
- updateSyncTime: 100
|
||||
+ updateSyncTime: 100,
|
||||
+ // overleaf: default to at top which is default CM6 behaviour
|
||||
+ unfilteredResultsAtEnd: false
|
||||
}, {
|
||||
defaultKeymap: (a, b) => a && b,
|
||||
closeOnBlur: (a, b) => a && b,
|
||||
@@ -744,6 +753,7 @@ function score(option) {
|
||||
(option.type ? 1 : 0);
|
||||
}
|
||||
function sortOptions(active, state) {
|
||||
+ var _a;
|
||||
let options = [];
|
||||
let sections = null;
|
||||
let addOption = (option) => {
|
||||
@@ -763,7 +773,8 @@ function sortOptions(active, state) {
|
||||
let getMatch = a.result.getMatch;
|
||||
if (a.result.filter === false) {
|
||||
for (let option of a.result.options) {
|
||||
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
|
||||
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
|
||||
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -790,15 +801,42 @@ function sortOptions(active, state) {
|
||||
}
|
||||
}
|
||||
let result = [], prev = null;
|
||||
+ const priorityIndices = new Map();
|
||||
let compare = conf.compareCompletions;
|
||||
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
|
||||
+ // overleaf: Deduplicate results with dedup options
|
||||
+ // The goal is to keep only the highest priority option, in the
|
||||
+ // highest scoring position.
|
||||
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
|
||||
+ if (key) {
|
||||
+ // Handle merging specifically for deduplicated items item
|
||||
+ const currentOptionIndex = priorityIndices.get(key);
|
||||
+ if (currentOptionIndex === undefined) {
|
||||
+ priorityIndices.set(key, result.length);
|
||||
+ result.push(opt);
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ else {
|
||||
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
|
||||
+ result[currentOptionIndex] = opt;
|
||||
+ if (currentOptionIndex === result.length - 1) {
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ // overleaf: end
|
||||
let cur = opt.completion;
|
||||
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
|
||||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
|
||||
- prev.apply != cur.apply || prev.boost != cur.boost)
|
||||
+ if (!prev || prev.label != cur.label)
|
||||
+ result.push(opt);
|
||||
+ // overleaf: we're already handling deduplication, so skip extra merges
|
||||
+ else if (prev.deduplicate)
|
||||
result.push(opt);
|
||||
else if (score(opt.completion) > score(prev))
|
||||
result[result.length - 1] = opt;
|
||||
+ else if (opt.completion.info)
|
||||
+ result[result.length - 1] = opt;
|
||||
prev = opt.completion;
|
||||
}
|
||||
return result;
|
||||
@@ -817,8 +855,9 @@ class CompletionDialog {
|
||||
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
|
||||
}
|
||||
static build(active, state, id, prev, conf, didSetActive) {
|
||||
- if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
- return prev.setDisabled();
|
||||
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
|
||||
+ // if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
+ // return prev.setDisabled()
|
||||
let options = sortOptions(active, state);
|
||||
if (!options.length)
|
||||
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
|
||||
@@ -1017,13 +1056,14 @@ const completionState = state.StateField.define({
|
||||
view.EditorView.contentAttributes.from(f, state => state.attrs)
|
||||
]
|
||||
});
|
||||
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
|
||||
function applyCompletion(view, option) {
|
||||
const apply = option.completion.apply || option.completion.label;
|
||||
let result = view.state.field(completionState).active.find(a => a.source == option.source);
|
||||
if (!(result instanceof ActiveResult))
|
||||
return false;
|
||||
if (typeof apply == "string")
|
||||
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
else
|
||||
apply(view, option.completion, result.from, result.to);
|
||||
return true;
|
||||
@@ -1559,20 +1599,42 @@ interpreted as indicating a placeholder.
|
||||
function snippet(template) {
|
||||
let snippet = Snippet.parse(template);
|
||||
return (editor, completion, from, to) => {
|
||||
- let { text, ranges } = snippet.instantiate(editor.state, from);
|
||||
- let { main } = editor.state.selection;
|
||||
- let spec = {
|
||||
- changes: { from, to: to == main.from ? main.to : to, insert: state.Text.of(text) },
|
||||
- scrollIntoView: true,
|
||||
- annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined
|
||||
- };
|
||||
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
+ let ranges = [];
|
||||
+ let totalOffset = 0;
|
||||
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
|
||||
+ if (range != main && from != to &&
|
||||
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
|
||||
+ return { range };
|
||||
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: range.from + toOff,
|
||||
+ insert: state.Text.of(text)
|
||||
+ };
|
||||
+ let originalTo = change.to;
|
||||
+ let offset = change.insert.length + fromOff;
|
||||
+ if (completion.extend) {
|
||||
+ completion.extend(editor.state, change);
|
||||
+ offset += originalTo - change.to;
|
||||
+ }
|
||||
+ for (const fieldRange of fieldRanges) {
|
||||
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
|
||||
+ }
|
||||
+ totalOffset += offset;
|
||||
+ return {
|
||||
+ changes: change,
|
||||
+ range: state.EditorSelection.cursor(change.from + change.insert.length)
|
||||
+ };
|
||||
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), state.Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
|
||||
if (ranges.length)
|
||||
spec.selection = fieldSelection(ranges, 0);
|
||||
if (ranges.some(r => r.field > 0)) {
|
||||
let active = new ActiveSnippet(ranges, 0);
|
||||
- let effects = spec.effects = [setActive.of(active)];
|
||||
- if (editor.state.field(snippetState, false) === undefined)
|
||||
- effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ spec.effects.push(setActive.of(active));
|
||||
+ if (editor.state.field(snippetState, false) === undefined) {
|
||||
+ spec.effects.push(state.StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ }
|
||||
}
|
||||
editor.dispatch(editor.state.update(spec));
|
||||
};
|
||||
@@ -1746,7 +1808,8 @@ const completeAnyWord = context => {
|
||||
const defaults = {
|
||||
brackets: ["(", "[", "{", "'", '"'],
|
||||
before: ")]}:;>",
|
||||
- stringPrefixes: []
|
||||
+ stringPrefixes: [],
|
||||
+ buildInsert: (state, range, open, close) => open + close,
|
||||
};
|
||||
const closeBracketEffect = state.StateEffect.define({
|
||||
map(value, mapping) {
|
||||
@@ -1854,8 +1917,8 @@ function insertBracket(state$1, bracket) {
|
||||
for (let tok of tokens) {
|
||||
let closed = closing(state.codePointAt(tok, 0));
|
||||
if (bracket == tok)
|
||||
- return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
- : handleOpen(state$1, tok, closed, conf.before || defaults.before);
|
||||
+ return closed == tok ? handleSame(state$1, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
+ : handleOpen(state$1, tok, closed, conf.before || defaults.before, conf);
|
||||
if (bracket == closed && closedBracketAt(state$1, state$1.selection.main.from))
|
||||
return handleClose(state$1, tok, closed);
|
||||
}
|
||||
@@ -1877,17 +1940,21 @@ function prevChar(doc, pos) {
|
||||
let prev = doc.sliceString(pos - 2, pos);
|
||||
return state.codePointSize(state.codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
|
||||
}
|
||||
-function handleOpen(state$1, open, close, closeBefore) {
|
||||
+function handleOpen(state$1, open, close, closeBefore, config) {
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state$1.changeByRange(range => {
|
||||
+ var _a;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + open.length),
|
||||
range: state.EditorSelection.range(range.anchor + open.length, range.head + open.length) };
|
||||
let next = nextChar(state$1.doc, range.head);
|
||||
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
|
||||
- return { changes: { insert: open + close, from: range.head },
|
||||
- effects: closeBracketEffect.of(range.head + open.length),
|
||||
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
|
||||
+ const insert = (_a = buildInsert(state$1, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
|
||||
+ return { changes: { insert, from: range.head },
|
||||
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
|
||||
range: state.EditorSelection.cursor(range.head + open.length) };
|
||||
+ }
|
||||
return { range: dont = range };
|
||||
});
|
||||
return dont ? null : state$1.update(changes, {
|
||||
@@ -1909,18 +1976,36 @@ function handleClose(state$1, _open, close) {
|
||||
}
|
||||
// Handles cases where the open and close token are the same, and
|
||||
// possibly triple quotes (as in `"""abc"""`-style quoting).
|
||||
-function handleSame(state$1, token, allowTriple, config) {
|
||||
+function handleSame(state$1, token, allowDouble, allowTriple, config) {
|
||||
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state$1.changeByRange(range => {
|
||||
+ var _a, _b, _c;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + token.length),
|
||||
range: state.EditorSelection.range(range.anchor + token.length, range.head + token.length) };
|
||||
let pos = range.head, next = nextChar(state$1.doc, pos), start;
|
||||
- if (next == token) {
|
||||
+ if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
+ (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state$1, start)) {
|
||||
+ return { changes: { insert: token + token + token + token, from: pos },
|
||||
+ effects: closeBracketEffect.of(pos + token.length),
|
||||
+ range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (allowDouble && state$1.sliceDoc(pos - token.length, pos) == token &&
|
||||
+ (start = canStartStringAt(state$1, pos - token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state$1, start)) {
|
||||
+ let insert = (_a = buildInsert(state$1, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
+ range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (next == token) {
|
||||
if (nodeStart(state$1, pos)) {
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ let insert = (_b = buildInsert(state$1, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: state.EditorSelection.cursor(pos + token.length) };
|
||||
}
|
||||
else if (closedBracketAt(state$1, pos)) {
|
||||
@@ -1930,18 +2015,13 @@ function handleSame(state$1, token, allowTriple, config) {
|
||||
range: state.EditorSelection.cursor(pos + content.length) };
|
||||
}
|
||||
}
|
||||
- else if (allowTriple && state$1.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
- (start = canStartStringAt(state$1, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
- nodeStart(state$1, start)) {
|
||||
- return { changes: { insert: token + token + token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
- range: state.EditorSelection.cursor(pos + token.length) };
|
||||
- }
|
||||
else if (state$1.charCategorizer(pos)(next) != state.CharCategory.Word) {
|
||||
- if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes))
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ if (canStartStringAt(state$1, pos, stringPrefixes) > -1 && !probablyInString(state$1, pos, token, stringPrefixes)) {
|
||||
+ const insert = (_c = buildInsert(state$1, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: state.EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
}
|
||||
return { range: dont = range };
|
||||
});
|
||||
@@ -2086,6 +2166,7 @@ exports.completionKeymap = completionKeymap;
|
||||
exports.completionStatus = completionStatus;
|
||||
exports.currentCompletions = currentCompletions;
|
||||
exports.deleteBracketPair = deleteBracketPair;
|
||||
+exports.getCompletionTooltip = getCompletionTooltip;
|
||||
exports.hasNextSnippetField = hasNextSnippetField;
|
||||
exports.hasPrevSnippetField = hasPrevSnippetField;
|
||||
exports.ifIn = ifIn;
|
||||
@@ -2093,8 +2174,10 @@ exports.ifNotIn = ifNotIn;
|
||||
exports.insertBracket = insertBracket;
|
||||
exports.insertCompletionText = insertCompletionText;
|
||||
exports.moveCompletionSelection = moveCompletionSelection;
|
||||
+exports.nextChar = nextChar;
|
||||
exports.nextSnippetField = nextSnippetField;
|
||||
exports.pickedCompletion = pickedCompletion;
|
||||
+exports.prevChar = prevChar;
|
||||
exports.prevSnippetField = prevSnippetField;
|
||||
exports.selectedCompletion = selectedCompletion;
|
||||
exports.selectedCompletionIndex = selectedCompletionIndex;
|
||||
diff --git a/dist/index.d.cts b/dist/index.d.cts
|
||||
index b57b8f6..fce47ab 100644
|
||||
--- a/dist/index.d.cts
|
||||
+++ b/dist/index.d.cts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
|
||||
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
|
||||
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
|
||||
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
|
||||
import * as _lezer_common from '@lezer/common';
|
||||
|
||||
/**
|
||||
@@ -73,6 +73,19 @@ interface Completion {
|
||||
a `{name}` object.
|
||||
*/
|
||||
section?: string | CompletionSection;
|
||||
+ /**
|
||||
+ Can be used to alter the change created when the completion is applied
|
||||
+ */
|
||||
+ extend?: ExtendCompletion;
|
||||
+ /**
|
||||
+ If multiple sources return the same result, use this field to specifiy a
|
||||
+ deduplication key as well as a priority. For each unique key, only the
|
||||
+ completion with the highest priority will be shown.
|
||||
+ */
|
||||
+ deduplicate?: {
|
||||
+ key: string;
|
||||
+ priority: number;
|
||||
+ };
|
||||
}
|
||||
/**
|
||||
The type returned from
|
||||
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
|
||||
picking a completion.
|
||||
*/
|
||||
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
|
||||
+type ExtendCompletion = (state: EditorState, change: {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ insert: string | Text;
|
||||
+}) => void;
|
||||
/**
|
||||
Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
|
||||
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
|
||||
|
||||
interface CompletionConfig {
|
||||
/**
|
||||
@@ -441,6 +459,10 @@ interface CompletionConfig {
|
||||
milliseconds.
|
||||
*/
|
||||
updateSyncTime?: number;
|
||||
+ /**
|
||||
+ overleaf: Move unfiltered results after the filtered ones
|
||||
+ */
|
||||
+ unfilteredResultsAtEnd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +536,8 @@ applies the snippet.
|
||||
*/
|
||||
declare function snippetCompletion(template: string, completion: Completion): Completion;
|
||||
|
||||
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
|
||||
+
|
||||
/**
|
||||
Returns a command that moves the completion selection forward or
|
||||
backward by the given amount.
|
||||
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
|
||||
these prefixes before the opening quote.
|
||||
*/
|
||||
stringPrefixes?: string[];
|
||||
+ /**
|
||||
+ An optional callback for overriding the content that's inserted
|
||||
+ based on surrounding characters
|
||||
+ */
|
||||
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
|
||||
}
|
||||
/**
|
||||
Extension to enable bracket-closing behavior. When a closeable
|
||||
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
|
||||
take care of running this for user input.)
|
||||
*/
|
||||
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
|
||||
+declare function nextChar(doc: Text, pos: number): string;
|
||||
+declare function prevChar(doc: Text, pos: number): string;
|
||||
|
||||
/**
|
||||
Returns an extension that enables autocompletion.
|
||||
@@ -636,4 +667,5 @@ the currently selected completion.
|
||||
*/
|
||||
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
|
||||
|
||||
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index b57b8f6..fce47ab 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
-import { EditorState, ChangeDesc, TransactionSpec, Transaction, StateCommand, Facet, Extension, StateEffect } from '@codemirror/state';
|
||||
-import { EditorView, Rect, KeyBinding, Command } from '@codemirror/view';
|
||||
+import { EditorState, Text, ChangeDesc, TransactionSpec, StateCommand, Transaction, Facet, SelectionRange, Extension, StateEffect } from '@codemirror/state';
|
||||
+import { EditorView, Rect, KeyBinding, Tooltip, Command } from '@codemirror/view';
|
||||
import * as _lezer_common from '@lezer/common';
|
||||
|
||||
/**
|
||||
@@ -73,6 +73,19 @@ interface Completion {
|
||||
a `{name}` object.
|
||||
*/
|
||||
section?: string | CompletionSection;
|
||||
+ /**
|
||||
+ Can be used to alter the change created when the completion is applied
|
||||
+ */
|
||||
+ extend?: ExtendCompletion;
|
||||
+ /**
|
||||
+ If multiple sources return the same result, use this field to specifiy a
|
||||
+ deduplication key as well as a priority. For each unique key, only the
|
||||
+ completion with the highest priority will be shown.
|
||||
+ */
|
||||
+ deduplicate?: {
|
||||
+ key: string;
|
||||
+ priority: number;
|
||||
+ };
|
||||
}
|
||||
/**
|
||||
The type returned from
|
||||
@@ -306,12 +319,17 @@ This annotation is added to transactions that are produced by
|
||||
picking a completion.
|
||||
*/
|
||||
declare const pickedCompletion: _codemirror_state.AnnotationType<Completion>;
|
||||
+type ExtendCompletion = (state: EditorState, change: {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ insert: string | Text;
|
||||
+}) => void;
|
||||
/**
|
||||
Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-declare function insertCompletionText(state: EditorState, text: string, from: number, to: number): TransactionSpec;
|
||||
+declare function insertCompletionText(state: EditorState, text: string | Text, from: number, to: number, extend?: ExtendCompletion): TransactionSpec;
|
||||
|
||||
interface CompletionConfig {
|
||||
/**
|
||||
@@ -441,6 +459,10 @@ interface CompletionConfig {
|
||||
milliseconds.
|
||||
*/
|
||||
updateSyncTime?: number;
|
||||
+ /**
|
||||
+ overleaf: Move unfiltered results after the filtered ones
|
||||
+ */
|
||||
+ unfilteredResultsAtEnd?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -514,6 +536,8 @@ applies the snippet.
|
||||
*/
|
||||
declare function snippetCompletion(template: string, completion: Completion): Completion;
|
||||
|
||||
+declare const getCompletionTooltip: (state: EditorState) => Tooltip | undefined | null;
|
||||
+
|
||||
/**
|
||||
Returns a command that moves the completion selection forward or
|
||||
backward by the given amount.
|
||||
@@ -562,6 +586,11 @@ interface CloseBracketConfig {
|
||||
these prefixes before the opening quote.
|
||||
*/
|
||||
stringPrefixes?: string[];
|
||||
+ /**
|
||||
+ An optional callback for overriding the content that's inserted
|
||||
+ based on surrounding characters
|
||||
+ */
|
||||
+ buildInsert?: (state: EditorState, range: SelectionRange, open: string, close: string) => string;
|
||||
}
|
||||
/**
|
||||
Extension to enable bracket-closing behavior. When a closeable
|
||||
@@ -593,6 +622,8 @@ to programmatically insert brackets—the
|
||||
take care of running this for user input.)
|
||||
*/
|
||||
declare function insertBracket(state: EditorState, bracket: string): Transaction | null;
|
||||
+declare function nextChar(doc: Text, pos: number): string;
|
||||
+declare function prevChar(doc: Text, pos: number): string;
|
||||
|
||||
/**
|
||||
Returns an extension that enables autocompletion.
|
||||
@@ -636,4 +667,5 @@ the currently selected completion.
|
||||
*/
|
||||
declare function setSelectedCompletion(index: number): StateEffect<unknown>;
|
||||
|
||||
-export { type CloseBracketConfig, type Completion, CompletionContext, type CompletionInfo, type CompletionResult, type CompletionSection, type CompletionSource, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export type { CloseBracketConfig, Completion, CompletionInfo, CompletionResult, CompletionSection, CompletionSource };
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 4729223..9361a53 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -1,4 +1,4 @@
|
||||
-import { Annotation, StateEffect, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Text, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
|
||||
+import { Annotation, StateEffect, Text, EditorSelection, codePointAt, codePointSize, fromCodePoint, Facet, combineConfig, StateField, Prec, Transaction, MapMode, RangeValue, RangeSet, CharCategory } from '@codemirror/state';
|
||||
import { Direction, logException, showTooltip, EditorView, ViewPlugin, getTooltip, Decoration, WidgetType, keymap } from '@codemirror/view';
|
||||
import { syntaxTree, indentUnit } from '@codemirror/language';
|
||||
|
||||
@@ -185,16 +185,23 @@ Helper function that returns a transaction spec which inserts a
|
||||
completion's text in the main selection range, and any other
|
||||
selection range that has the same text in front of it.
|
||||
*/
|
||||
-function insertCompletionText(state, text, from, to) {
|
||||
+function insertCompletionText(state, text, from, to, extend) {
|
||||
let { main } = state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
return Object.assign(Object.assign({}, state.changeByRange(range => {
|
||||
if (range != main && from != to &&
|
||||
state.sliceDoc(range.from + fromOff, range.from + toOff) != state.sliceDoc(from, to))
|
||||
return { range };
|
||||
- let lines = state.toText(text);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: to == main.from ? range.to : range.from + toOff,
|
||||
+ insert: text instanceof Text ? text : state.toText(text),
|
||||
+ };
|
||||
+ if (extend) {
|
||||
+ extend(state, change);
|
||||
+ }
|
||||
return {
|
||||
- changes: { from: range.from + fromOff, to: to == main.from ? range.to : range.from + toOff, insert: lines },
|
||||
- range: EditorSelection.cursor(range.from + fromOff + lines.length)
|
||||
+ changes: change,
|
||||
+ range: EditorSelection.cursor(change.from + change.insert.length)
|
||||
};
|
||||
})), { scrollIntoView: true, userEvent: "input.complete" });
|
||||
}
|
||||
@@ -387,7 +394,9 @@ const completionConfig = /*@__PURE__*/Facet.define({
|
||||
filterStrict: false,
|
||||
compareCompletions: (a, b) => a.label.localeCompare(b.label),
|
||||
interactionDelay: 75,
|
||||
- updateSyncTime: 100
|
||||
+ updateSyncTime: 100,
|
||||
+ // overleaf: default to at top which is default CM6 behaviour
|
||||
+ unfilteredResultsAtEnd: false
|
||||
}, {
|
||||
defaultKeymap: (a, b) => a && b,
|
||||
closeOnBlur: (a, b) => a && b,
|
||||
@@ -742,6 +751,7 @@ function score(option) {
|
||||
(option.type ? 1 : 0);
|
||||
}
|
||||
function sortOptions(active, state) {
|
||||
+ var _a;
|
||||
let options = [];
|
||||
let sections = null;
|
||||
let addOption = (option) => {
|
||||
@@ -761,7 +771,8 @@ function sortOptions(active, state) {
|
||||
let getMatch = a.result.getMatch;
|
||||
if (a.result.filter === false) {
|
||||
for (let option of a.result.options) {
|
||||
- addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], 1e9 - options.length));
|
||||
+ let defaultScore = conf.unfilteredResultsAtEnd ? -1e9 : 1e9;
|
||||
+ addOption(new Option(option, a.source, getMatch ? getMatch(option) : [], defaultScore - options.length));
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -788,15 +799,42 @@ function sortOptions(active, state) {
|
||||
}
|
||||
}
|
||||
let result = [], prev = null;
|
||||
+ const priorityIndices = new Map();
|
||||
let compare = conf.compareCompletions;
|
||||
for (let opt of options.sort((a, b) => (b.score - a.score) || compare(a.completion, b.completion))) {
|
||||
+ // overleaf: Deduplicate results with dedup options
|
||||
+ // The goal is to keep only the highest priority option, in the
|
||||
+ // highest scoring position.
|
||||
+ const key = (_a = opt.completion.deduplicate) === null || _a === void 0 ? void 0 : _a.key;
|
||||
+ if (key) {
|
||||
+ // Handle merging specifically for deduplicated items item
|
||||
+ const currentOptionIndex = priorityIndices.get(key);
|
||||
+ if (currentOptionIndex === undefined) {
|
||||
+ priorityIndices.set(key, result.length);
|
||||
+ result.push(opt);
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ else {
|
||||
+ if (result[currentOptionIndex].completion.deduplicate.priority < opt.completion.deduplicate.priority) {
|
||||
+ result[currentOptionIndex] = opt;
|
||||
+ if (currentOptionIndex === result.length - 1) {
|
||||
+ prev = opt.completion;
|
||||
+ }
|
||||
+ }
|
||||
+ }
|
||||
+ continue;
|
||||
+ }
|
||||
+ // overleaf: end
|
||||
let cur = opt.completion;
|
||||
- if (!prev || prev.label != cur.label || prev.detail != cur.detail ||
|
||||
- (prev.type != null && cur.type != null && prev.type != cur.type) ||
|
||||
- prev.apply != cur.apply || prev.boost != cur.boost)
|
||||
+ if (!prev || prev.label != cur.label)
|
||||
+ result.push(opt);
|
||||
+ // overleaf: we're already handling deduplication, so skip extra merges
|
||||
+ else if (prev.deduplicate)
|
||||
result.push(opt);
|
||||
else if (score(opt.completion) > score(prev))
|
||||
result[result.length - 1] = opt;
|
||||
+ else if (opt.completion.info)
|
||||
+ result[result.length - 1] = opt;
|
||||
prev = opt.completion;
|
||||
}
|
||||
return result;
|
||||
@@ -815,8 +853,9 @@ class CompletionDialog {
|
||||
: new CompletionDialog(this.options, makeAttrs(id, selected), this.tooltip, this.timestamp, selected, this.disabled);
|
||||
}
|
||||
static build(active, state, id, prev, conf, didSetActive) {
|
||||
- if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
- return prev.setDisabled();
|
||||
+ // Overleaf: avoid setting the previous completion state to disabled while completion sources are pending
|
||||
+ // if (prev && !didSetActive && active.some(s => s.isPending))
|
||||
+ // return prev.setDisabled()
|
||||
let options = sortOptions(active, state);
|
||||
if (!options.length)
|
||||
return prev && active.some(a => a.isPending) ? prev.setDisabled() : null;
|
||||
@@ -1015,13 +1054,14 @@ const completionState = /*@__PURE__*/StateField.define({
|
||||
EditorView.contentAttributes.from(f, state => state.attrs)
|
||||
]
|
||||
});
|
||||
+const getCompletionTooltip = (state) => { var _a; return (_a = state.field(completionState, false)) === null || _a === void 0 ? void 0 : _a.tooltip; };
|
||||
function applyCompletion(view, option) {
|
||||
const apply = option.completion.apply || option.completion.label;
|
||||
let result = view.state.field(completionState).active.find(a => a.source == option.source);
|
||||
if (!(result instanceof ActiveResult))
|
||||
return false;
|
||||
if (typeof apply == "string")
|
||||
- view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
+ view.dispatch(Object.assign(Object.assign({}, insertCompletionText(view.state, apply, result.from, result.to, option.completion.extend)), { annotations: pickedCompletion.of(option.completion) }));
|
||||
else
|
||||
apply(view, option.completion, result.from, result.to);
|
||||
return true;
|
||||
@@ -1557,20 +1597,42 @@ interpreted as indicating a placeholder.
|
||||
function snippet(template) {
|
||||
let snippet = Snippet.parse(template);
|
||||
return (editor, completion, from, to) => {
|
||||
- let { text, ranges } = snippet.instantiate(editor.state, from);
|
||||
- let { main } = editor.state.selection;
|
||||
- let spec = {
|
||||
- changes: { from, to: to == main.from ? main.to : to, insert: Text.of(text) },
|
||||
- scrollIntoView: true,
|
||||
- annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined
|
||||
- };
|
||||
+ let { main } = editor.state.selection, fromOff = from - main.from, toOff = to - main.from;
|
||||
+ let ranges = [];
|
||||
+ let totalOffset = 0;
|
||||
+ let spec = Object.assign(Object.assign({}, editor.state.changeByRange(range => {
|
||||
+ if (range != main && from != to &&
|
||||
+ editor.state.sliceDoc(range.from + fromOff, range.from + toOff) != editor.state.sliceDoc(from, to))
|
||||
+ return { range };
|
||||
+ let { text, ranges: fieldRanges } = snippet.instantiate(editor.state, range.from + fromOff);
|
||||
+ let change = {
|
||||
+ from: range.from + fromOff,
|
||||
+ to: range.from + toOff,
|
||||
+ insert: Text.of(text)
|
||||
+ };
|
||||
+ let originalTo = change.to;
|
||||
+ let offset = change.insert.length + fromOff;
|
||||
+ if (completion.extend) {
|
||||
+ completion.extend(editor.state, change);
|
||||
+ offset += originalTo - change.to;
|
||||
+ }
|
||||
+ for (const fieldRange of fieldRanges) {
|
||||
+ ranges.push(new FieldRange(fieldRange.field, fieldRange.from + totalOffset, fieldRange.to + totalOffset));
|
||||
+ }
|
||||
+ totalOffset += offset;
|
||||
+ return {
|
||||
+ changes: change,
|
||||
+ range: EditorSelection.cursor(change.from + change.insert.length)
|
||||
+ };
|
||||
+ })), { scrollIntoView: true, annotations: completion ? [pickedCompletion.of(completion), Transaction.userEvent.of("input.complete")] : undefined, effects: [] });
|
||||
if (ranges.length)
|
||||
spec.selection = fieldSelection(ranges, 0);
|
||||
if (ranges.some(r => r.field > 0)) {
|
||||
let active = new ActiveSnippet(ranges, 0);
|
||||
- let effects = spec.effects = [setActive.of(active)];
|
||||
- if (editor.state.field(snippetState, false) === undefined)
|
||||
- effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ spec.effects.push(setActive.of(active));
|
||||
+ if (editor.state.field(snippetState, false) === undefined) {
|
||||
+ spec.effects.push(StateEffect.appendConfig.of([snippetState, addSnippetKeymap, snippetPointerHandler, baseTheme]));
|
||||
+ }
|
||||
}
|
||||
editor.dispatch(editor.state.update(spec));
|
||||
};
|
||||
@@ -1744,7 +1806,8 @@ const completeAnyWord = context => {
|
||||
const defaults = {
|
||||
brackets: ["(", "[", "{", "'", '"'],
|
||||
before: ")]}:;>",
|
||||
- stringPrefixes: []
|
||||
+ stringPrefixes: [],
|
||||
+ buildInsert: (state, range, open, close) => open + close,
|
||||
};
|
||||
const closeBracketEffect = /*@__PURE__*/StateEffect.define({
|
||||
map(value, mapping) {
|
||||
@@ -1852,8 +1915,8 @@ function insertBracket(state, bracket) {
|
||||
for (let tok of tokens) {
|
||||
let closed = closing(codePointAt(tok, 0));
|
||||
if (bracket == tok)
|
||||
- return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
- : handleOpen(state, tok, closed, conf.before || defaults.before);
|
||||
+ return closed == tok ? handleSame(state, tok, tokens.indexOf(tok + tok) > -1, tokens.indexOf(tok + tok + tok) > -1, conf)
|
||||
+ : handleOpen(state, tok, closed, conf.before || defaults.before, conf);
|
||||
if (bracket == closed && closedBracketAt(state, state.selection.main.from))
|
||||
return handleClose(state, tok, closed);
|
||||
}
|
||||
@@ -1875,17 +1938,21 @@ function prevChar(doc, pos) {
|
||||
let prev = doc.sliceString(pos - 2, pos);
|
||||
return codePointSize(codePointAt(prev, 0)) == prev.length ? prev : prev.slice(1);
|
||||
}
|
||||
-function handleOpen(state, open, close, closeBefore) {
|
||||
+function handleOpen(state, open, close, closeBefore, config) {
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
+ var _a;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: open, from: range.from }, { insert: close, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + open.length),
|
||||
range: EditorSelection.range(range.anchor + open.length, range.head + open.length) };
|
||||
let next = nextChar(state.doc, range.head);
|
||||
- if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1)
|
||||
- return { changes: { insert: open + close, from: range.head },
|
||||
- effects: closeBracketEffect.of(range.head + open.length),
|
||||
+ if (!next || /\s/.test(next) || closeBefore.indexOf(next) > -1) {
|
||||
+ const insert = (_a = buildInsert(state, range, open, close)) !== null && _a !== void 0 ? _a : open + close;
|
||||
+ return { changes: { insert, from: range.head },
|
||||
+ effects: insert === open ? [] : closeBracketEffect.of(range.head + open.length),
|
||||
range: EditorSelection.cursor(range.head + open.length) };
|
||||
+ }
|
||||
return { range: dont = range };
|
||||
});
|
||||
return dont ? null : state.update(changes, {
|
||||
@@ -1907,18 +1974,36 @@ function handleClose(state, _open, close) {
|
||||
}
|
||||
// Handles cases where the open and close token are the same, and
|
||||
// possibly triple quotes (as in `"""abc"""`-style quoting).
|
||||
-function handleSame(state, token, allowTriple, config) {
|
||||
+function handleSame(state, token, allowDouble, allowTriple, config) {
|
||||
let stringPrefixes = config.stringPrefixes || defaults.stringPrefixes;
|
||||
+ let buildInsert = config.buildInsert || defaults.buildInsert;
|
||||
let dont = null, changes = state.changeByRange(range => {
|
||||
+ var _a, _b, _c;
|
||||
if (!range.empty)
|
||||
return { changes: [{ insert: token, from: range.from }, { insert: token, from: range.to }],
|
||||
effects: closeBracketEffect.of(range.to + token.length),
|
||||
range: EditorSelection.range(range.anchor + token.length, range.head + token.length) };
|
||||
let pos = range.head, next = nextChar(state.doc, pos), start;
|
||||
- if (next == token) {
|
||||
+ if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
+ (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state, start)) {
|
||||
+ return { changes: { insert: token + token + token + token, from: pos },
|
||||
+ effects: closeBracketEffect.of(pos + token.length),
|
||||
+ range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (allowDouble && state.sliceDoc(pos - token.length, pos) == token &&
|
||||
+ (start = canStartStringAt(state, pos - token.length, stringPrefixes)) > -1 &&
|
||||
+ nodeStart(state, start)) {
|
||||
+ let insert = (_a = buildInsert(state, range, token, token)) !== null && _a !== void 0 ? _a : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
+ range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
+ else if (next == token) {
|
||||
if (nodeStart(state, pos)) {
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ let insert = (_b = buildInsert(state, range, token, token)) !== null && _b !== void 0 ? _b : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length) };
|
||||
}
|
||||
else if (closedBracketAt(state, pos)) {
|
||||
@@ -1928,18 +2013,13 @@ function handleSame(state, token, allowTriple, config) {
|
||||
range: EditorSelection.cursor(pos + content.length) };
|
||||
}
|
||||
}
|
||||
- else if (allowTriple && state.sliceDoc(pos - 2 * token.length, pos) == token + token &&
|
||||
- (start = canStartStringAt(state, pos - 2 * token.length, stringPrefixes)) > -1 &&
|
||||
- nodeStart(state, start)) {
|
||||
- return { changes: { insert: token + token + token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
- range: EditorSelection.cursor(pos + token.length) };
|
||||
- }
|
||||
else if (state.charCategorizer(pos)(next) != CharCategory.Word) {
|
||||
- if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes))
|
||||
- return { changes: { insert: token + token, from: pos },
|
||||
- effects: closeBracketEffect.of(pos + token.length),
|
||||
+ if (canStartStringAt(state, pos, stringPrefixes) > -1 && !probablyInString(state, pos, token, stringPrefixes)) {
|
||||
+ const insert = (_c = buildInsert(state, range, token, token)) !== null && _c !== void 0 ? _c : token + token;
|
||||
+ return { changes: { insert, from: pos },
|
||||
+ effects: insert === token ? [] : closeBracketEffect.of(pos + token.length),
|
||||
range: EditorSelection.cursor(pos + token.length) };
|
||||
+ }
|
||||
}
|
||||
return { range: dont = range };
|
||||
});
|
||||
@@ -2071,4 +2151,4 @@ function setSelectedCompletion(index) {
|
||||
return setSelectedEffect.of(index);
|
||||
}
|
||||
|
||||
-export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextSnippetField, pickedCompletion, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
+export { CompletionContext, acceptCompletion, autocompletion, clearSnippet, closeBrackets, closeBracketsKeymap, closeCompletion, completeAnyWord, completeFromList, completionKeymap, completionStatus, currentCompletions, deleteBracketPair, getCompletionTooltip, hasNextSnippetField, hasPrevSnippetField, ifIn, ifNotIn, insertBracket, insertCompletionText, moveCompletionSelection, nextChar, nextSnippetField, pickedCompletion, prevChar, prevSnippetField, selectedCompletion, selectedCompletionIndex, setSelectedCompletion, snippet, snippetCompletion, snippetKeymap, startCompletion };
|
||||
@@ -1,381 +0,0 @@
|
||||
diff --git a/dist/index.cjs b/dist/index.cjs
|
||||
index 46231ae..fb0f9aa 100644
|
||||
--- a/dist/index.cjs
|
||||
+++ b/dist/index.cjs
|
||||
@@ -592,6 +592,7 @@ class SearchQuery {
|
||||
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
|
||||
this.unquoted = this.unquote(this.search);
|
||||
this.wholeWord = !!config.wholeWord;
|
||||
+ this.scope = config.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -606,7 +607,7 @@ class SearchQuery {
|
||||
eq(other) {
|
||||
return this.search == other.search && this.replace == other.replace &&
|
||||
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
|
||||
- this.wholeWord == other.wholeWord;
|
||||
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -631,7 +632,12 @@ class QueryType {
|
||||
}
|
||||
}
|
||||
function stringCursor(spec, state, from, to) {
|
||||
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
|
||||
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, buffer, bufferPos) => {
|
||||
+ return (!test || test(from, to, buffer, bufferPos))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
|
||||
}
|
||||
function stringWordTest(doc, categorizer) {
|
||||
return (from, to, buf, bufPos) => {
|
||||
@@ -695,9 +701,14 @@ class StringQuery extends QueryType {
|
||||
}
|
||||
}
|
||||
function regexpCursor(spec, state, from, to) {
|
||||
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, match) => {
|
||||
+ return (!test || test(from, to, match))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
return new RegExpCursor(state.doc, spec.search, {
|
||||
ignoreCase: !spec.caseSensitive,
|
||||
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
|
||||
+ test: testWithinScope,
|
||||
}, from, to);
|
||||
}
|
||||
function charBefore(str, index) {
|
||||
@@ -737,10 +748,18 @@ class RegExpQuery extends QueryType {
|
||||
this.prevMatchInRange(state, curTo, state.doc.length);
|
||||
}
|
||||
getReplacement(result) {
|
||||
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
|
||||
- : i == "&" ? result.match[0]
|
||||
- : i != "0" && +i < result.match.length ? result.match[i]
|
||||
- : m);
|
||||
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
|
||||
+ if (i == "&")
|
||||
+ return result.match[0];
|
||||
+ if (i == "$")
|
||||
+ return "$";
|
||||
+ for (let l = i.length; l > 0; l--) {
|
||||
+ let n = +i.slice(0, l);
|
||||
+ if (n > 0 && n < result.match.length)
|
||||
+ return result.match[n] + i.slice(l);
|
||||
+ }
|
||||
+ return m;
|
||||
+ });
|
||||
}
|
||||
matchAll(state, limit) {
|
||||
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
|
||||
@@ -1227,7 +1246,9 @@ const searchExtensions = [
|
||||
exports.RegExpCursor = RegExpCursor;
|
||||
exports.SearchCursor = SearchCursor;
|
||||
exports.SearchQuery = SearchQuery;
|
||||
+exports.StringQuery = StringQuery;
|
||||
exports.closeSearchPanel = closeSearchPanel;
|
||||
+exports.createSearchPanel = createSearchPanel;
|
||||
exports.findNext = findNext;
|
||||
exports.findPrevious = findPrevious;
|
||||
exports.getSearchQuery = getSearchQuery;
|
||||
@@ -1242,4 +1263,6 @@ exports.searchPanelOpen = searchPanelOpen;
|
||||
exports.selectMatches = selectMatches;
|
||||
exports.selectNextOccurrence = selectNextOccurrence;
|
||||
exports.selectSelectionMatches = selectSelectionMatches;
|
||||
+exports.selectWord = selectWord;
|
||||
exports.setSearchQuery = setSearchQuery;
|
||||
+exports.togglePanel = togglePanel;
|
||||
diff --git a/dist/index.d.cts b/dist/index.d.cts
|
||||
index 08f5696..663d192 100644
|
||||
--- a/dist/index.d.cts
|
||||
+++ b/dist/index.d.cts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
|
||||
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
|
||||
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
A search cursor provides an iterator over text matches in a
|
||||
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
|
||||
itself will be highlighted with `"cm-selectionMatch-main"`.
|
||||
*/
|
||||
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
|
||||
+declare const selectWord: StateCommand;
|
||||
/**
|
||||
Select next occurrence of the current selection. Expand selection
|
||||
to the surrounding word when the selection is empty.
|
||||
@@ -264,6 +265,13 @@ declare class SearchQuery {
|
||||
*/
|
||||
readonly wholeWord: boolean;
|
||||
/**
|
||||
+ When set, only include search matches within these ranges
|
||||
+ */
|
||||
+ readonly scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
+ /**
|
||||
Create a query object.
|
||||
*/
|
||||
constructor(config: {
|
||||
@@ -293,6 +301,13 @@ declare class SearchQuery {
|
||||
Enable whole-word matching.
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
+ /**
|
||||
+ The ranges to match within
|
||||
+ */
|
||||
+ scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
});
|
||||
/**
|
||||
Compare this query to another query.
|
||||
@@ -307,6 +322,34 @@ declare class SearchQuery {
|
||||
to: number;
|
||||
}>;
|
||||
}
|
||||
+type SearchResult = typeof SearchCursor.prototype.value;
|
||||
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
|
||||
+ readonly spec: SearchQuery;
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract getReplacement(result: Result): string;
|
||||
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
|
||||
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
+declare class StringQuery extends QueryType<SearchResult> {
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ private prevMatchInRange;
|
||||
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ getReplacement(_result: SearchResult): string;
|
||||
+ matchAll(state: EditorState, limit: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[] | null;
|
||||
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
/**
|
||||
A state effect that updates the current search query. Note that
|
||||
this only has an effect if the search state has been initialized
|
||||
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
|
||||
once).
|
||||
*/
|
||||
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
|
||||
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
|
||||
/**
|
||||
Get the current search query from an editor state.
|
||||
*/
|
||||
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
|
||||
replacement.
|
||||
*/
|
||||
declare const replaceAll: Command;
|
||||
+declare function createSearchPanel(view: EditorView): Panel;
|
||||
/**
|
||||
Make sure the search panel is open and focused.
|
||||
*/
|
||||
@@ -372,4 +417,4 @@ Default search-related key bindings.
|
||||
*/
|
||||
declare const searchKeymap: readonly KeyBinding[];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
diff --git a/dist/index.d.ts b/dist/index.d.ts
|
||||
index 08f5696..663d192 100644
|
||||
--- a/dist/index.d.ts
|
||||
+++ b/dist/index.d.ts
|
||||
@@ -1,6 +1,6 @@
|
||||
import * as _codemirror_state from '@codemirror/state';
|
||||
import { Text, Extension, StateCommand, EditorState, SelectionRange, StateEffect } from '@codemirror/state';
|
||||
-import { Command, KeyBinding, EditorView, Panel } from '@codemirror/view';
|
||||
+import { Command, EditorView, Panel, KeyBinding } from '@codemirror/view';
|
||||
|
||||
/**
|
||||
A search cursor provides an iterator over text matches in a
|
||||
@@ -161,6 +161,7 @@ the `"cm-selectionMatch"` class for the highlighting. When
|
||||
itself will be highlighted with `"cm-selectionMatch-main"`.
|
||||
*/
|
||||
declare function highlightSelectionMatches(options?: HighlightOptions): Extension;
|
||||
+declare const selectWord: StateCommand;
|
||||
/**
|
||||
Select next occurrence of the current selection. Expand selection
|
||||
to the surrounding word when the selection is empty.
|
||||
@@ -264,6 +265,13 @@ declare class SearchQuery {
|
||||
*/
|
||||
readonly wholeWord: boolean;
|
||||
/**
|
||||
+ When set, only include search matches within these ranges
|
||||
+ */
|
||||
+ readonly scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
+ /**
|
||||
Create a query object.
|
||||
*/
|
||||
constructor(config: {
|
||||
@@ -293,6 +301,13 @@ declare class SearchQuery {
|
||||
Enable whole-word matching.
|
||||
*/
|
||||
wholeWord?: boolean;
|
||||
+ /**
|
||||
+ The ranges to match within
|
||||
+ */
|
||||
+ scope?: Readonly<{
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[]>;
|
||||
});
|
||||
/**
|
||||
Compare this query to another query.
|
||||
@@ -307,6 +322,34 @@ declare class SearchQuery {
|
||||
to: number;
|
||||
}>;
|
||||
}
|
||||
+type SearchResult = typeof SearchCursor.prototype.value;
|
||||
+declare abstract class QueryType<Result extends SearchResult = SearchResult> {
|
||||
+ readonly spec: SearchQuery;
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ abstract nextMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract prevMatch(state: EditorState, curFrom: number, curTo: number): Result | null;
|
||||
+ abstract getReplacement(result: Result): string;
|
||||
+ abstract matchAll(state: EditorState, limit: number): readonly Result[] | null;
|
||||
+ abstract highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
+declare class StringQuery extends QueryType<SearchResult> {
|
||||
+ constructor(spec: SearchQuery);
|
||||
+ nextMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ private prevMatchInRange;
|
||||
+ prevMatch(state: EditorState, curFrom: number, curTo: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ } | null;
|
||||
+ getReplacement(_result: SearchResult): string;
|
||||
+ matchAll(state: EditorState, limit: number): {
|
||||
+ from: number;
|
||||
+ to: number;
|
||||
+ }[] | null;
|
||||
+ highlight(state: EditorState, from: number, to: number, add: (from: number, to: number) => void): void;
|
||||
+}
|
||||
/**
|
||||
A state effect that updates the current search query. Note that
|
||||
this only has an effect if the search state has been initialized
|
||||
@@ -315,6 +358,7 @@ by running [`openSearchPanel`](https://codemirror.net/6/docs/ref/#search.openSea
|
||||
once).
|
||||
*/
|
||||
declare const setSearchQuery: _codemirror_state.StateEffectType<SearchQuery>;
|
||||
+declare const togglePanel: _codemirror_state.StateEffectType<boolean>;
|
||||
/**
|
||||
Get the current search query from an editor state.
|
||||
*/
|
||||
@@ -353,6 +397,7 @@ Replace all instances of the search query with the given
|
||||
replacement.
|
||||
*/
|
||||
declare const replaceAll: Command;
|
||||
+declare function createSearchPanel(view: EditorView): Panel;
|
||||
/**
|
||||
Make sure the search panel is open and focused.
|
||||
*/
|
||||
@@ -372,4 +417,4 @@ Default search-related key bindings.
|
||||
*/
|
||||
declare const searchKeymap: readonly KeyBinding[];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
diff --git a/dist/index.js b/dist/index.js
|
||||
index 22172ef..08a9974 100644
|
||||
--- a/dist/index.js
|
||||
+++ b/dist/index.js
|
||||
@@ -590,6 +590,7 @@ class SearchQuery {
|
||||
this.valid = !!this.search && (!this.regexp || validRegExp(this.search));
|
||||
this.unquoted = this.unquote(this.search);
|
||||
this.wholeWord = !!config.wholeWord;
|
||||
+ this.scope = config.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -604,7 +605,7 @@ class SearchQuery {
|
||||
eq(other) {
|
||||
return this.search == other.search && this.replace == other.replace &&
|
||||
this.caseSensitive == other.caseSensitive && this.regexp == other.regexp &&
|
||||
- this.wholeWord == other.wholeWord;
|
||||
+ this.wholeWord == other.wholeWord && this.scope == other.scope;
|
||||
}
|
||||
/**
|
||||
@internal
|
||||
@@ -629,7 +630,12 @@ class QueryType {
|
||||
}
|
||||
}
|
||||
function stringCursor(spec, state, from, to) {
|
||||
- return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined);
|
||||
+ const test = spec.wholeWord ? stringWordTest(state.doc, state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, buffer, bufferPos) => {
|
||||
+ return (!test || test(from, to, buffer, bufferPos))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
+ return new SearchCursor(state.doc, spec.unquoted, from, to, spec.caseSensitive ? undefined : x => x.toLowerCase(), testWithinScope);
|
||||
}
|
||||
function stringWordTest(doc, categorizer) {
|
||||
return (from, to, buf, bufPos) => {
|
||||
@@ -693,9 +699,14 @@ class StringQuery extends QueryType {
|
||||
}
|
||||
}
|
||||
function regexpCursor(spec, state, from, to) {
|
||||
+ const test = spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined;
|
||||
+ const testWithinScope = (from, to, match) => {
|
||||
+ return (!test || test(from, to, match))
|
||||
+ && (!spec.scope || spec.scope.some(range => from >= range.from && from <= range.to && to >= range.from && to <= range.to));
|
||||
+ };
|
||||
return new RegExpCursor(state.doc, spec.search, {
|
||||
ignoreCase: !spec.caseSensitive,
|
||||
- test: spec.wholeWord ? regexpWordTest(state.charCategorizer(state.selection.main.head)) : undefined
|
||||
+ test: testWithinScope,
|
||||
}, from, to);
|
||||
}
|
||||
function charBefore(str, index) {
|
||||
@@ -735,10 +746,18 @@ class RegExpQuery extends QueryType {
|
||||
this.prevMatchInRange(state, curTo, state.doc.length);
|
||||
}
|
||||
getReplacement(result) {
|
||||
- return this.spec.unquote(this.spec.replace).replace(/\$([$&\d+])/g, (m, i) => i == "$" ? "$"
|
||||
- : i == "&" ? result.match[0]
|
||||
- : i != "0" && +i < result.match.length ? result.match[i]
|
||||
- : m);
|
||||
+ return this.spec.unquote(this.spec.replace).replace(/\$([$&]|\d+)/g, (m, i) => {
|
||||
+ if (i == "&")
|
||||
+ return result.match[0];
|
||||
+ if (i == "$")
|
||||
+ return "$";
|
||||
+ for (let l = i.length; l > 0; l--) {
|
||||
+ let n = +i.slice(0, l);
|
||||
+ if (n > 0 && n < result.match.length)
|
||||
+ return result.match[n] + i.slice(l);
|
||||
+ }
|
||||
+ return m;
|
||||
+ });
|
||||
}
|
||||
matchAll(state, limit) {
|
||||
let cursor = regexpCursor(this.spec, state, 0, state.doc.length), ranges = [];
|
||||
@@ -1222,4 +1241,4 @@ const searchExtensions = [
|
||||
baseTheme
|
||||
];
|
||||
|
||||
-export { RegExpCursor, SearchCursor, SearchQuery, closeSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, setSearchQuery };
|
||||
+export { RegExpCursor, SearchCursor, SearchQuery, StringQuery, closeSearchPanel, createSearchPanel, findNext, findPrevious, getSearchQuery, gotoLine, highlightSelectionMatches, openSearchPanel, replaceAll, replaceNext, search, searchKeymap, searchPanelOpen, selectMatches, selectNextOccurrence, selectSelectionMatches, selectWord, setSearchQuery, togglePanel };
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,30 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 2fae107d03b30aff9320d135ec79c049c51f298a..32ec707ddbf8937e3e130c3deac1060c308bf439 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
-const {PassThrough} = require('stream');
|
||||
+const {PassThrough, pipeline} = require('stream');
|
||||
const extend = require('extend');
|
||||
|
||||
let debug = () => {};
|
||||
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
.on('complete', (...params) => handleFinish(params))
|
||||
.on('finish', (...params) => handleFinish(params));
|
||||
|
||||
- requestStream.pipe(delayStream);
|
||||
+ pipeline(requestStream, delayStream, () => {});
|
||||
} else {
|
||||
activeRequest = opts.request(requestOpts, onResponse);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
// No more attempts need to be made, just continue on.
|
||||
if (streamMode) {
|
||||
retryStream.emit('response', response);
|
||||
- delayStream.pipe(retryStream);
|
||||
+ pipeline(delayStream, retryStream, () => {});
|
||||
requestStream.on('error', err => {
|
||||
retryStream.destroy(err);
|
||||
});
|
||||
@@ -1,30 +0,0 @@
|
||||
diff --git a/index.js b/index.js
|
||||
index 298a351097d70a7fb005b6961f4d58247e391d8f..6a809ace0349d40cb2b6732ad66fd8ad208698ca 100644
|
||||
--- a/index.js
|
||||
+++ b/index.js
|
||||
@@ -1,6 +1,6 @@
|
||||
'use strict';
|
||||
|
||||
-const {PassThrough} = require('stream');
|
||||
+const {PassThrough, pipeline} = require('stream');
|
||||
const extend = require('extend');
|
||||
|
||||
let debug = () => {};
|
||||
@@ -185,7 +185,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
.on('complete', (...params) => handleFinish(params))
|
||||
.on('finish', (...params) => handleFinish(params));
|
||||
|
||||
- requestStream.pipe(delayStream);
|
||||
+ pipeline(requestStream, delayStream, () => {});
|
||||
} else {
|
||||
activeRequest = opts.request(requestOpts, onResponse);
|
||||
}
|
||||
@@ -251,7 +251,7 @@ function retryRequest(requestOpts, opts, callback) {
|
||||
// No more attempts need to be made, just continue on.
|
||||
if (streamMode) {
|
||||
retryStream.emit('response', response);
|
||||
- delayStream.pipe(retryStream);
|
||||
+ pipeline(delayStream, retryStream, () => {});
|
||||
requestStream.on('error', err => {
|
||||
retryStream.destroy(err);
|
||||
});
|
||||
@@ -1,57 +0,0 @@
|
||||
diff --git a/lib/sandboxed_module.js b/lib/sandboxed_module.js
|
||||
index 1cd6743..4718b97 100644
|
||||
--- a/lib/sandboxed_module.js
|
||||
+++ b/lib/sandboxed_module.js
|
||||
@@ -4,7 +4,7 @@ var Module = require('module');
|
||||
var fs = require('fs');
|
||||
var vm = require('vm');
|
||||
var path = require('path');
|
||||
-var builtinModules = require('./builtin_modules.json');
|
||||
+var builtinModules = Module.builtinModules || require('./builtin_modules.json');
|
||||
var parent = module.parent;
|
||||
var globalOptions = {};
|
||||
var registeredBuiltInSourceTransformers = ['coffee'];
|
||||
@@ -157,12 +157,20 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||
var cache = Object.create(null);
|
||||
var required = this._getRequires();
|
||||
for (var key in required) {
|
||||
- var injectedFilename = requireLike(this.filename).resolve(key);
|
||||
- cache[injectedFilename] = required[key];
|
||||
+ // Under Yarn PnP, resolution from a transitive dependency's context may fail
|
||||
+ // for packages not declared in that dependency's package.json. Silently skip
|
||||
+ // cache pre-population on failure; the mock will still be injected via the
|
||||
+ // inject map in requireInterceptor or resolved via RecursiveRequireProxy fallback.
|
||||
+ try {
|
||||
+ var injectedFilename = requireLike(this.filename).resolve(key);
|
||||
+ cache[injectedFilename] = required[key];
|
||||
+ } catch (e) {}
|
||||
}
|
||||
cache[this.filename] = this.exports;
|
||||
var globals = this.globals;
|
||||
|
||||
+ // Store the top-level module's filename for PnP fallback resolution
|
||||
+ var topLevelFilename = this.filename;
|
||||
var options;
|
||||
if(!this._options.sourceTransformersSingleOnly && this._options.sourceTransformers){
|
||||
options = {
|
||||
@@ -208,8 +216,18 @@ SandboxedModule.prototype._createRecursiveRequireProxy = function() {
|
||||
if (request in cache) return cache[request];
|
||||
return require(request);
|
||||
}
|
||||
- // cached modules
|
||||
- var requestedFilename = requireLike(this.filename).resolve(request);
|
||||
+ // Resolve the requested module filename.
|
||||
+ // Under Yarn PnP, packages can only resolve their declared dependencies.
|
||||
+ // When sandboxed-module loads a transitive dependency, the resolution context
|
||||
+ // may not have access to all needed packages. Fall back to resolving from
|
||||
+ // the top-level module's context (the module under test).
|
||||
+ var requestedFilename;
|
||||
+ try {
|
||||
+ requestedFilename = requireLike(this.filename).resolve(request);
|
||||
+ } catch (e) {
|
||||
+ if (this.filename === topLevelFilename) throw e;
|
||||
+ requestedFilename = requireLike(topLevelFilename).resolve(request);
|
||||
+ }
|
||||
if (requestedFilename in cache) return cache[requestedFilename];
|
||||
var sandboxedModule = createInnerSandboxedModule(requestedFilename)
|
||||
return sandboxedModule.exports;
|
||||
-25
@@ -1,25 +0,0 @@
|
||||
approvedGitRepositories:
|
||||
- "**"
|
||||
|
||||
enableGlobalCache: false
|
||||
|
||||
enableScripts: true
|
||||
|
||||
enableTelemetry: false
|
||||
|
||||
nodeLinker: node-modules
|
||||
|
||||
npmMinimalAgeGate: 3d
|
||||
|
||||
supportedArchitectures:
|
||||
cpu:
|
||||
- current
|
||||
- arm64
|
||||
- x64
|
||||
libc:
|
||||
- current
|
||||
- glibc
|
||||
os:
|
||||
- current
|
||||
- darwin
|
||||
- linux
|
||||
+8
-4
@@ -1,9 +1,11 @@
|
||||
# Contributing to Overleaf CE+
|
||||
Contributing to Overleaf CE+
|
||||
============================
|
||||
|
||||
Thank you for reading this! If you'd like to report a bug or join in the development
|
||||
of Overleaf CE+, then here are some notes on how to do that.
|
||||
|
||||
## Reporting bugs and opening issues
|
||||
Reporting bugs and opening issues
|
||||
---------------------------------
|
||||
|
||||
If you'd like to report a bug or open an issue, please **[check if there is an existing issue](https://github.com/yu-i-i/overleaf-cep).**
|
||||
If there is then please add any more information that you have, or give it a 👍.
|
||||
@@ -13,7 +15,8 @@ reproduce the bug, which situations it appears in, what you expected to happen,
|
||||
If you can include a screenshot for front end issues that is very helpful.
|
||||
|
||||
|
||||
## Pull Requests
|
||||
Pull Requests
|
||||
-------------
|
||||
|
||||
See [Overleaf wiki](https://github.com/overleaf/overleaf/wiki)
|
||||
for how to manage the Overleaf development environment and for our developer guidelines.
|
||||
@@ -24,6 +27,7 @@ to get your changes merged into Overleaf.
|
||||
|
||||
If you're looking for something to work on, have a look at the [open issues](https://github.com/yu-i-i/overleaf-cep/issues).
|
||||
|
||||
## Security
|
||||
Security
|
||||
--------
|
||||
|
||||
Please see [Overleaf security policy](https://github.com/overleaf/overleaf/security/policy) if you would like to report a potential security vulnerability.
|
||||
|
||||
+29
-12
@@ -5,21 +5,34 @@ In most cases the original code has been modified, optimized, or extended.
|
||||
|
||||
## Symbol palette
|
||||
|
||||
The symbol palette feature is based on the
|
||||
[original Overleaf implementation](https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette)
|
||||
The symbol palette feature is based on the original Overleaf implementation:
|
||||
|
||||
https://github.com/overleaf/web/tree/master/frontend/js/features/symbol-palette
|
||||
|
||||
The original code was slightly improved, particularly in the parts related to keyboard input.
|
||||
|
||||
## LDAP authentication
|
||||
|
||||
LDAP authentication was inspired by [this project](https://github.com/smhaller/ldap-overleaf-sl).
|
||||
LDAP authentication was inspired by:
|
||||
|
||||
https://github.com/smhaller/ldap-overleaf-sl
|
||||
|
||||
The project provided the idea and part of the implementation for adding LDAP users to the user's contacts.
|
||||
|
||||
## Autocomplete of reference keys
|
||||
|
||||
Autocomplete of reference keys is based on:
|
||||
|
||||
https://github.com/lcpu-club/overleaf
|
||||
|
||||
The implementation largely follows the referenced code, except that a different `.bib` file parser is used.
|
||||
|
||||
## Real-time track changes and comments
|
||||
|
||||
The Track Changes and Comments feature exists largely in the original Overleaf codebase.\
|
||||
The missing parts were implemented based on [this code](https://github.com/ertuil/overleaf).
|
||||
The missing parts were implemented based on:
|
||||
|
||||
https://github.com/ertuil/overleaf
|
||||
|
||||
The referenced code was fixed, optimized, and extended.
|
||||
|
||||
@@ -35,19 +48,23 @@ The missing proxy component was implemented in this project.
|
||||
|
||||
## Git integration
|
||||
|
||||
The Git integration feature includes parts of the frontend code
|
||||
from [here](https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge)
|
||||
Git integration is based on:
|
||||
|
||||
## GitHub synchronization
|
||||
https://github.com/ayaka-notes/overleaf-pro/tree/feat-git-bridge
|
||||
|
||||
The GitHub synchronization feature includes parts of the frontend code and the OAuth2 backend implementation
|
||||
from [here](https://github.com/ayaka-notes/overleaf-pro/commit/06a30fe9a0ed75e5ab40b50a8a4e94f43161cf71).
|
||||
The backend was completely rewritten.\
|
||||
The frontend uses the referenced code, which was optimized and improved.
|
||||
|
||||
## Sign Up page
|
||||
## Original Features
|
||||
|
||||
The Sign Up page is based on [this code](https://github.com/ayaka-notes/overleaf-pro/tree/feat-public-registeration).
|
||||
The following features are original work implemented in this project:
|
||||
|
||||
# Acknowledgments
|
||||
- Template Gallery
|
||||
- Advanced administrator tools for managing user accounts and projects
|
||||
- SAML authentication
|
||||
- OpenID Connect authentication
|
||||
|
||||
## Acknowledgments
|
||||
|
||||
Thanks to the users of the project for valuable feedback, suggestions,
|
||||
and help in identifying and fixing bugs.
|
||||
|
||||
@@ -25,22 +25,17 @@
|
||||
|
||||
The present "extended" version of Overleaf CE includes:
|
||||
|
||||
- Template gallery
|
||||
- Sandboxed compiles with TeX Live image selection
|
||||
- Sign Up page
|
||||
- LDAP authentication
|
||||
- SAML authentication
|
||||
- OpenID Connect authentication
|
||||
- Real-time track changes and comments
|
||||
- Autocomplete of reference keys
|
||||
- Symbol palette
|
||||
- Template gallery
|
||||
- Import file from external URL
|
||||
- Git integration
|
||||
- GitHub synchronization
|
||||
- Zotero integration
|
||||
- Reference Search and Pick Tool
|
||||
- Document Import (`.docx`, `.md`) and Export (`.docx`, `.md`, `.html`)
|
||||
- Advanced administrator tools for managing user accounts and projects
|
||||
- Logo tools
|
||||
- Git integration
|
||||
|
||||
> [!CAUTION]
|
||||
> Overleaf Community Edition is intended for use in environments where **all** users are trusted. Community Edition is **not** appropriate for scenarios where isolation of users is required due to Sandbox Compiles not being available. When not using Sandboxed Compiles, users have full read and write access to the `sharelatex` container resources (filesystem, network, environment variables) when running LaTeX compiles.
|
||||
@@ -81,13 +76,13 @@ folder are added.
|
||||
## Authors
|
||||
|
||||
[The Overleaf Team](https://www.overleaf.com/about)\
|
||||
[yu-i-i](https://github.com/yu-i-i), [davrot](https://github.com/davrot) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
|
||||
[yu-i-i](https://github.com/yu-i-i/overleaf-cep) — CE extensions; references to adapted code are listed in [`CREDITS`](CREDITS.md)
|
||||
|
||||
## License
|
||||
|
||||
The code in this repository is released under the GNU AFFERO GENERAL PUBLIC LICENSE, version 3. A copy can be found in the [`LICENSE`](LICENSE) file.
|
||||
|
||||
Copyright (c) Overleaf, 2014-2026.\
|
||||
Copyright (c) @yu-i-i, 2024-2026, for CE extensions.
|
||||
Copyright (c) yu-i-i, 2024-2026, for CE extensions.
|
||||
|
||||
Portions of the code are derived from other open-source projects; see [`CREDITS`](CREDITS.md).
|
||||
|
||||
@@ -19,6 +19,18 @@ bin/up
|
||||
|
||||
Once the services are running, open <http://localhost/launchpad> to create the first admin account.
|
||||
|
||||
## TeX Live
|
||||
|
||||
Compiling a PDF requires building a TeX Live image to handle the compilation inside Docker:
|
||||
|
||||
```shell
|
||||
docker build texlive -t texlive-full
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> To compile on a macOS host, you may need to override the path to the Docker socket by creating a `.env` file in this directory, containing
|
||||
> `DOCKER_SOCKET_PATH=/var/run/docker.sock.raw`
|
||||
|
||||
## Development
|
||||
|
||||
To avoid running `bin/build && bin/up` after every code change, you can run Overleaf
|
||||
@@ -59,11 +71,13 @@ each service:
|
||||
| `web` | 9229 |
|
||||
| `clsi` | 9230 |
|
||||
| `chat` | 9231 |
|
||||
| `contacts` | 9232 |
|
||||
| `docstore` | 9233 |
|
||||
| `document-updater` | 9234 |
|
||||
| `filestore` | 9235 |
|
||||
| `notifications` | 9236 |
|
||||
| `real-time` | 9237 |
|
||||
| `references` | 9238 |
|
||||
| `history-v1` | 9239 |
|
||||
| `project-history` | 9240 |
|
||||
| `linked-url-proxy` | 9241 |
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
CHAT_HOST=chat
|
||||
CLSI_HOST=clsi
|
||||
DOWNLOAD_HOST=clsi-nginx
|
||||
CONTACTS_HOST=contacts
|
||||
DOCSTORE_HOST=docstore
|
||||
DOCUMENT_UPDATER_HOST=document-updater
|
||||
FILESTORE_HOST=filestore
|
||||
@@ -17,6 +17,7 @@ QUEUES_REDIS_HOST=redis
|
||||
DSMP_REDIS_HOST=redis
|
||||
REALTIME_HOST=real-time
|
||||
REDIS_HOST=redis
|
||||
REFERENCES_HOST=references
|
||||
SESSION_SECRET=foo
|
||||
V1_HISTORY_HOST=history-v1
|
||||
WEBPACK_HOST=webpack
|
||||
|
||||
@@ -21,6 +21,17 @@ services:
|
||||
- ../services/chat/app.js:/overleaf/services/chat/app.js
|
||||
- ../services/chat/config:/overleaf/services/chat/config
|
||||
|
||||
contacts:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9232:9229"
|
||||
volumes:
|
||||
- ../services/contacts/app:/overleaf/services/contacts/app
|
||||
- ../services/contacts/app.js:/overleaf/services/contacts/app.js
|
||||
- ../services/contacts/config:/overleaf/services/contacts/config
|
||||
|
||||
docstore:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
@@ -112,6 +123,17 @@ services:
|
||||
- ../services/real-time/app.js:/overleaf/services/real-time/app.js
|
||||
- ../services/real-time/config:/overleaf/services/real-time/config
|
||||
|
||||
references:
|
||||
command: ["node", "--watch", "app.js"]
|
||||
environment:
|
||||
- NODE_OPTIONS=--inspect=0.0.0.0:9229
|
||||
ports:
|
||||
- "127.0.0.1:9238:9229"
|
||||
volumes:
|
||||
- ../services/references/app:/overleaf/services/references/app
|
||||
- ../services/references/config:/overleaf/services/references/config
|
||||
- ../services/references/app.js:/overleaf/services/references/app.js
|
||||
|
||||
web:
|
||||
command: ["node", "--watch", "app.mjs", "--watch-locales"]
|
||||
environment:
|
||||
|
||||
+27
-21
@@ -22,11 +22,13 @@ services:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/clsi/Dockerfile
|
||||
target: with-texlive
|
||||
env_file:
|
||||
- dev.env
|
||||
environment:
|
||||
- SANDBOXED_COMPILES=false
|
||||
- TEXLIVE_IMAGE=texlive-full # docker build texlive -t texlive-full
|
||||
- SANDBOXED_COMPILES=true
|
||||
- SANDBOXED_COMPILES_HOST_DIR_COMPILES=${PWD}/compiles
|
||||
- SANDBOXED_COMPILES_HOST_DIR_OUTPUT=${PWD}/output
|
||||
user: root
|
||||
volumes:
|
||||
- ${PWD}/compiles:/overleaf/services/clsi/compiles
|
||||
@@ -34,16 +36,12 @@ services:
|
||||
- ${DOCKER_SOCKET_PATH:-/var/run/docker.sock}:/var/run/docker.sock
|
||||
- clsi-cache:/overleaf/services/clsi/cache
|
||||
|
||||
clsi-nginx:
|
||||
image: nginx:1.28
|
||||
read_only: true
|
||||
tmpfs:
|
||||
- /tmp
|
||||
- /var/cache/nginx
|
||||
- /run
|
||||
volumes:
|
||||
- ${PWD}/output:/output:ro
|
||||
- ../services/clsi/nginx.conf:/etc/nginx/conf.d/nginx.conf:ro
|
||||
contacts:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/contacts/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
|
||||
docstore:
|
||||
build:
|
||||
@@ -65,8 +63,8 @@ services:
|
||||
dockerfile: services/filestore/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
# environment:
|
||||
# - ENABLE_CONVERSIONS=true
|
||||
# environment:
|
||||
# - ENABLE_CONVERSIONS=true
|
||||
volumes:
|
||||
- filestore-public-files:/overleaf/services/filestore/public_files
|
||||
- filestore-template-files:/overleaf/services/filestore/template_files
|
||||
@@ -77,7 +75,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: services/history-v1/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
- dev.env
|
||||
environment:
|
||||
OVERLEAF_EDITOR_ANALYTICS_BUCKET: "/buckets/analytics"
|
||||
OVERLEAF_EDITOR_BLOBS_BUCKET: "/buckets/blobs"
|
||||
@@ -86,7 +84,7 @@ services:
|
||||
OVERLEAF_EDITOR_ZIPS_BUCKET: "/buckets/zips"
|
||||
PERSISTOR_BACKEND: fs
|
||||
volumes:
|
||||
- history-v1-buckets:/buckets
|
||||
- history-v1-buckets:/buckets
|
||||
|
||||
linked-url-proxy:
|
||||
build:
|
||||
@@ -96,7 +94,7 @@ services:
|
||||
- dev.env
|
||||
|
||||
mongo:
|
||||
image: mongo:8
|
||||
image: mongo:6.0
|
||||
command: --replSet overleaf
|
||||
ports:
|
||||
- "127.0.0.1:27017:27017" # for debugging
|
||||
@@ -123,7 +121,7 @@ services:
|
||||
context: ..
|
||||
dockerfile: services/project-history/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
- dev.env
|
||||
|
||||
real-time:
|
||||
build:
|
||||
@@ -133,12 +131,19 @@ services:
|
||||
- dev.env
|
||||
|
||||
redis:
|
||||
image: redis:7
|
||||
image: redis:5
|
||||
ports:
|
||||
- "127.0.0.1:6379:6379" # for debugging
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
|
||||
references:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/references/Dockerfile
|
||||
env_file:
|
||||
- dev.env
|
||||
|
||||
web:
|
||||
build:
|
||||
context: ..
|
||||
@@ -161,6 +166,7 @@ services:
|
||||
- redis
|
||||
- chat
|
||||
- clsi
|
||||
- contacts
|
||||
- docstore
|
||||
- document-updater
|
||||
- filestore
|
||||
@@ -169,14 +175,14 @@ services:
|
||||
- notifications
|
||||
- project-history
|
||||
- real-time
|
||||
- references
|
||||
|
||||
webpack:
|
||||
build:
|
||||
context: ..
|
||||
dockerfile: services/web/Dockerfile
|
||||
target: webpack
|
||||
command:
|
||||
["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
|
||||
command: ["npx", "webpack", "serve", "--config", "webpack.config.dev-env.js"]
|
||||
ports:
|
||||
- "127.0.0.1:80:3808"
|
||||
volumes:
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
FROM debian:testing-slim
|
||||
|
||||
RUN apt-get update
|
||||
RUN apt-cache depends texlive-full | grep "Depends: " | grep -v -- "-doc" | grep -v -- "-lang-" | sed 's/Depends: //' | xargs apt-get install -y --no-install-recommends
|
||||
RUN apt-get install -y --no-install-recommends fontconfig inkscape pandoc python3-pygments
|
||||
|
||||
RUN useradd tex
|
||||
USER tex
|
||||
+21
-21
@@ -1,23 +1,23 @@
|
||||
version: "2.2"
|
||||
version: '2.2'
|
||||
services:
|
||||
sharelatex:
|
||||
ports:
|
||||
- 30000:30000
|
||||
- 30150:30150
|
||||
- 30120:30120
|
||||
- 30050:30050
|
||||
- 30420:30420
|
||||
- 30030:30030
|
||||
- 30160:30160
|
||||
- 30360:30360
|
||||
- 30130:30130
|
||||
- 30100:30100
|
||||
- 30540:30540
|
||||
- 30640:30640
|
||||
- 40000:40000
|
||||
sharelatex:
|
||||
ports:
|
||||
- 30000:30000
|
||||
- 30150:30150
|
||||
- 30120:30120
|
||||
- 30050:30050
|
||||
- 30420:30420
|
||||
- 30030:30030
|
||||
- 30160:30160
|
||||
- 30360:30360
|
||||
- 30130:30130
|
||||
- 30100:30100
|
||||
- 30540:30540
|
||||
- 30640:30640
|
||||
- 40000:40000
|
||||
|
||||
# Server Pro
|
||||
- 30070:30070
|
||||
- 30400:30400
|
||||
environment:
|
||||
DEBUG_NODE: "true"
|
||||
# Server Pro
|
||||
- 30070:30070
|
||||
- 30400:30400
|
||||
environment:
|
||||
DEBUG_NODE: 'true'
|
||||
|
||||
+127
-128
@@ -1,152 +1,151 @@
|
||||
services:
|
||||
sharelatex:
|
||||
restart: always
|
||||
# Server Pro users:
|
||||
# image: quay.io/sharelatex/sharelatex-pro
|
||||
image: sharelatex/sharelatex
|
||||
container_name: sharelatex
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 80:80
|
||||
stop_grace_period: 60s
|
||||
volumes:
|
||||
- ~/sharelatex_data:/var/lib/overleaf
|
||||
########################################################################
|
||||
#### Server Pro: Uncomment the following line to mount the docker ####
|
||||
#### socket, required for Sibling Containers to work ####
|
||||
########################################################################
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
OVERLEAF_APP_NAME: Overleaf Community Edition
|
||||
sharelatex:
|
||||
restart: always
|
||||
# Server Pro users:
|
||||
# image: quay.io/sharelatex/sharelatex-pro
|
||||
image: sharelatex/sharelatex
|
||||
container_name: sharelatex
|
||||
depends_on:
|
||||
mongo:
|
||||
condition: service_healthy
|
||||
redis:
|
||||
condition: service_started
|
||||
ports:
|
||||
- 80:80
|
||||
stop_grace_period: 60s
|
||||
volumes:
|
||||
- ~/sharelatex_data:/var/lib/overleaf
|
||||
########################################################################
|
||||
#### Server Pro: Uncomment the following line to mount the docker ####
|
||||
#### socket, required for Sibling Containers to work ####
|
||||
########################################################################
|
||||
# - /var/run/docker.sock:/var/run/docker.sock
|
||||
environment:
|
||||
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
||||
OVERLEAF_APP_NAME: Overleaf Community Edition
|
||||
|
||||
# Same property, unfortunately with different names in
|
||||
# different locations
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
OVERLEAF_MONGO_URL: mongodb://mongo/sharelatex
|
||||
|
||||
# IMPORTANT: Required for invite token generation
|
||||
# OVERLEAF_INVITE_TOKEN_SECRET: generate with `openssl rand -base64 32`
|
||||
# Same property, unfortunately with different names in
|
||||
# different locations
|
||||
OVERLEAF_REDIS_HOST: redis
|
||||
REDIS_HOST: redis
|
||||
|
||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,zotero"
|
||||
ENABLED_LINKED_FILE_TYPES: "project_file,project_output_file,url,zotero"
|
||||
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS: "true"
|
||||
# Enables Thumbnail generation using ImageMagick
|
||||
ENABLE_CONVERSIONS: 'true'
|
||||
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED: "true"
|
||||
# Disables email confirmation requirement
|
||||
EMAIL_CONFIRMATION_DISABLED: 'true'
|
||||
|
||||
## Set for SSL via nginx-proxy
|
||||
#VIRTUAL_HOST: 103.112.212.22
|
||||
## Set for SSL via nginx-proxy
|
||||
#VIRTUAL_HOST: 103.112.212.22
|
||||
|
||||
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
||||
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
||||
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
|
||||
# OVERLEAF_ADMIN_EMAIL: support@it.com
|
||||
# OVERLEAF_SITE_URL: http://overleaf.example.com
|
||||
# OVERLEAF_NAV_TITLE: Overleaf Community Edition
|
||||
# OVERLEAF_HEADER_IMAGE_URL: http://example.com/mylogo.png
|
||||
# OVERLEAF_ADMIN_EMAIL: support@it.com
|
||||
|
||||
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
|
||||
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
|
||||
# OVERLEAF_LEFT_FOOTER: '[{"text": "Another page I want to link to can be found <a href=\"here\">here</a>"} ]'
|
||||
# OVERLEAF_RIGHT_FOOTER: '[{"text": "Hello I am on the Right"} ]'
|
||||
|
||||
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
|
||||
# OVERLEAF_EMAIL_FROM_ADDRESS: "hello@example.com"
|
||||
|
||||
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
|
||||
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
|
||||
# OVERLEAF_EMAIL_AWS_SES_ACCESS_KEY_ID:
|
||||
# OVERLEAF_EMAIL_AWS_SES_SECRET_KEY:
|
||||
|
||||
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
|
||||
# OVERLEAF_EMAIL_SMTP_PORT: 587
|
||||
# OVERLEAF_EMAIL_SMTP_SECURE: false
|
||||
# OVERLEAF_EMAIL_SMTP_USER:
|
||||
# OVERLEAF_EMAIL_SMTP_PASS:
|
||||
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
|
||||
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
|
||||
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
|
||||
# OVERLEAF_EMAIL_SMTP_LOGGER: true
|
||||
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
|
||||
# OVERLEAF_EMAIL_SMTP_HOST: smtp.example.com
|
||||
# OVERLEAF_EMAIL_SMTP_PORT: 587
|
||||
# OVERLEAF_EMAIL_SMTP_SECURE: false
|
||||
# OVERLEAF_EMAIL_SMTP_USER:
|
||||
# OVERLEAF_EMAIL_SMTP_PASS:
|
||||
# OVERLEAF_EMAIL_SMTP_TLS_REJECT_UNAUTH: true
|
||||
# OVERLEAF_EMAIL_SMTP_IGNORE_TLS: false
|
||||
# OVERLEAF_EMAIL_SMTP_NAME: '127.0.0.1'
|
||||
# OVERLEAF_EMAIL_SMTP_LOGGER: true
|
||||
# OVERLEAF_CUSTOM_EMAIL_FOOTER: "This system is run by department x"
|
||||
|
||||
# ENABLE_CRON_RESOURCE_DELETION: true
|
||||
# ENABLE_CRON_RESOURCE_DELETION: true
|
||||
|
||||
################
|
||||
## Server Pro ##
|
||||
################
|
||||
################
|
||||
## Server Pro ##
|
||||
################
|
||||
|
||||
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
|
||||
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
|
||||
## so the following environment variables must be commented out to avoid compile issues.
|
||||
##
|
||||
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
|
||||
SANDBOXED_COMPILES: "true"
|
||||
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: "/home/user/sharelatex_data/data/compiles"
|
||||
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: "/home/user/sharelatex_data/data/output"
|
||||
### Backwards compatibility (before Server Pro 5.5)
|
||||
DOCKER_RUNNER: "true"
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: "true"
|
||||
## The Community Edition is intended for use in environments where all users are trusted and is not appropriate for
|
||||
## scenarios where isolation of users is required. Sandboxed Compiles are not available in the Community Edition,
|
||||
## so the following environment variables must be commented out to avoid compile issues.
|
||||
##
|
||||
## Sandboxed Compiles: https://docs.overleaf.com/on-premises/configuration/overleaf-toolkit/server-pro-only-configuration/sandboxed-compiles
|
||||
SANDBOXED_COMPILES: 'true'
|
||||
### Bind-mount source for /var/lib/overleaf/data/compiles inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_COMPILES: '/home/user/sharelatex_data/data/compiles'
|
||||
### Bind-mount source for /var/lib/overleaf/data/output inside the container.
|
||||
SANDBOXED_COMPILES_HOST_DIR_OUTPUT: '/home/user/sharelatex_data/data/output'
|
||||
### Backwards compatibility (before Server Pro 5.5)
|
||||
DOCKER_RUNNER: 'true'
|
||||
SANDBOXED_COMPILES_SIBLING_CONTAINERS: 'true'
|
||||
|
||||
## Works with test LDAP server shown at bottom of docker compose
|
||||
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
|
||||
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
|
||||
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
|
||||
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
|
||||
# OVERLEAF_LDAP_NAME_ATT: 'cn'
|
||||
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
|
||||
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
|
||||
## Works with test LDAP server shown at bottom of docker compose
|
||||
# OVERLEAF_LDAP_URL: 'ldap://ldap:389'
|
||||
# OVERLEAF_LDAP_SEARCH_BASE: 'ou=people,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_SEARCH_FILTER: '(uid={{username}})'
|
||||
# OVERLEAF_LDAP_BIND_DN: 'cn=admin,dc=planetexpress,dc=com'
|
||||
# OVERLEAF_LDAP_BIND_CREDENTIALS: 'GoodNewsEveryone'
|
||||
# OVERLEAF_LDAP_EMAIL_ATT: 'mail'
|
||||
# OVERLEAF_LDAP_NAME_ATT: 'cn'
|
||||
# OVERLEAF_LDAP_LAST_NAME_ATT: 'sn'
|
||||
# OVERLEAF_LDAP_UPDATE_USER_DETAILS_ON_LOGIN: 'true'
|
||||
|
||||
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
|
||||
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
|
||||
# OVERLEAF_TEMPLATES_USER_ID: "578773160210479700917ee5"
|
||||
# OVERLEAF_NEW_PROJECT_TEMPLATE_LINKS: '[ {"name":"All Templates","url":"/templates/all"}]'
|
||||
|
||||
# OVERLEAF_PROXY_LEARN: "true"
|
||||
|
||||
mongo:
|
||||
restart: always
|
||||
image: mongo:8.0
|
||||
container_name: mongo
|
||||
command: "--replSet overleaf"
|
||||
volumes:
|
||||
- ~/mongo_data:/data/db
|
||||
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the replica set.
|
||||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
# OVERLEAF_PROXY_LEARN: "true"
|
||||
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:6.2
|
||||
container_name: redis
|
||||
volumes:
|
||||
- ~/redis_data:/data
|
||||
mongo:
|
||||
restart: always
|
||||
image: mongo:6.0
|
||||
container_name: mongo
|
||||
command: '--replSet overleaf'
|
||||
volumes:
|
||||
- ~/mongo_data:/data/db
|
||||
- ./bin/shared/mongodb-init-replica-set.js:/docker-entrypoint-initdb.d/mongodb-init-replica-set.js
|
||||
environment:
|
||||
MONGO_INITDB_DATABASE: sharelatex
|
||||
extra_hosts:
|
||||
# Required when using the automatic database setup for initializing the replica set.
|
||||
# This override is not needed when running the setup after starting up mongo.
|
||||
- mongo:127.0.0.1
|
||||
healthcheck:
|
||||
test: echo 'db.stats().ok' | mongosh localhost:27017/test --quiet
|
||||
interval: 10s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ldap:
|
||||
# restart: always
|
||||
# image: rroemhild/test-openldap
|
||||
# container_name: ldap
|
||||
redis:
|
||||
restart: always
|
||||
image: redis:6.2
|
||||
container_name: redis
|
||||
volumes:
|
||||
- ~/redis_data:/data
|
||||
|
||||
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
|
||||
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
|
||||
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
|
||||
# but the example here can be used if you'd prefer to run everything with docker-compose
|
||||
# ldap:
|
||||
# restart: always
|
||||
# image: rroemhild/test-openldap
|
||||
# container_name: ldap
|
||||
|
||||
# nginx-proxy:
|
||||
# image: jwilder/nginx-proxy
|
||||
# container_name: nginx-proxy
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
# - /home/overleaf/tmp:/etc/nginx/certs
|
||||
# See https://github.com/jwilder/nginx-proxy for documentation on how to configure the nginx-proxy container,
|
||||
# and https://github.com/overleaf/overleaf/wiki/HTTPS-reverse-proxy-using-Nginx for an example of some recommended
|
||||
# settings. We recommend using a properly managed nginx instance outside of the Overleaf Server Pro setup,
|
||||
# but the example here can be used if you'd prefer to run everything with docker-compose
|
||||
|
||||
# nginx-proxy:
|
||||
# image: jwilder/nginx-proxy
|
||||
# container_name: nginx-proxy
|
||||
# ports:
|
||||
# - "80:80"
|
||||
# - "443:443"
|
||||
# volumes:
|
||||
# - /var/run/docker.sock:/tmp/docker.sock:ro
|
||||
# - /home/overleaf/tmp:/etc/nginx/certs
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
FROM cypress/included:15.12.0
|
||||
ARG USER_UID=1000
|
||||
ARG USER_GID=1000
|
||||
|
||||
# Corepack setup, shared between all the images.
|
||||
ENV PATH="/overleaf/node_modules/.bin:$PATH"
|
||||
ENV COREPACK_HOME=/opt/corepack
|
||||
RUN corepack enable && corepack install -g yarn@4.14.1
|
||||
ENV COREPACK_ENABLE_NETWORK=0
|
||||
|
||||
WORKDIR /overleaf
|
||||
|
||||
RUN sed -i s/node:x:1000:/node:x:${USER_GID}:/ /etc/group \
|
||||
&& sed -i s_node:x:1000:1000::/home/node:/bin/bash_node:x:${USER_UID}:${USER_GID}::/home/node:/bin/bash_ /etc/passwd \
|
||||
&& chown -R node:node /home/node \
|
||||
&& chown node:node /overleaf
|
||||
|
||||
USER node
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -4,7 +4,6 @@ access-token-encryptor
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/access-token-encryptor
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
|
||||
@@ -4,17 +4,19 @@
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "",
|
||||
"license": "AGPL-3.0-only",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1"
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"chai": "^4.3.6",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,160 +0,0 @@
|
||||
// French typographic spacing rules for JSON string values.
|
||||
// Per https://typographisme.net/post/Les-espaces-typographiques-et-le-web
|
||||
// - NNBSP before ? ! ;
|
||||
// - NBSP before :
|
||||
// - NBSP inside guillemets (after «, before ») — keeps the
|
||||
// guillemet attached to its content so it cannot be orphaned at a
|
||||
// line break
|
||||
// - NNBSP between digit groups (thousands separator)
|
||||
// - NBSP between a number and a following word/unit (50 Mo, 5 jours)
|
||||
// - NBSP before %
|
||||
// - point médian "·" instead of parenthetical (e) for inclusive writing
|
||||
|
||||
const NBSP = ' '
|
||||
const NNBSP = ' '
|
||||
|
||||
function autoFix(value) {
|
||||
let v = value
|
||||
// Normalize whitespace before ? ! ; to NNBSP
|
||||
v = v.replace(/\s*([?!;])/g, `${NNBSP}$1`)
|
||||
v = v.replace(/([^\s])([?!;])/g, `$1${NNBSP}$2`)
|
||||
// Normalize whitespace before : (after a letter, not before /) to NBSP
|
||||
v = v.replace(/(\p{L})\s*:(?!\/)/gu, `$1${NBSP}:`)
|
||||
// NBSP after «
|
||||
v = v.replace(/«\s*/g, `«${NBSP}`)
|
||||
// NBSP before »
|
||||
v = v.replace(/\s*»/g, `${NBSP}»`)
|
||||
// NNBSP between digits (thousands separator)
|
||||
v = v.replace(/(\d)\s+(\d)/g, `$1${NNBSP}$2`)
|
||||
// NBSP between digit and following letter (number + unit/word)
|
||||
v = v.replace(/(\d)\s+(\p{L})/gu, `$1${NBSP}$2`)
|
||||
// NBSP before %
|
||||
v = v.replace(/(\d)\s*%/g, `$1${NBSP}%`)
|
||||
return v
|
||||
}
|
||||
|
||||
function lint(value) {
|
||||
const violations = []
|
||||
for (const m of value.matchAll(/(.)([?!;])/g)) {
|
||||
if (m[1] !== NNBSP) {
|
||||
violations.push({
|
||||
message: `expected NNBSP before "${m[2]}", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\p{L})(\s*):(?!\/)/gu)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before ":" after letter ${JSON.stringify(m[1])}, got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/«(.)/g)) {
|
||||
if (m[1] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP after "«", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(.)»/g)) {
|
||||
if (m[1] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before "»", got ${JSON.stringify(m[1])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s)(\d)/g)) {
|
||||
if (m[2] !== NNBSP) {
|
||||
violations.push({
|
||||
message: `expected NNBSP between digits "${m[1]}${m[3]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s)(\p{L})/gu)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP between digit "${m[1]}" and "${m[3]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
for (const m of value.matchAll(/(\d)(\s*)%/g)) {
|
||||
if (m[2] !== NBSP) {
|
||||
violations.push({
|
||||
message: `expected NBSP before "%" after "${m[1]}", got ${JSON.stringify(m[2])}`,
|
||||
fixable: true,
|
||||
})
|
||||
}
|
||||
}
|
||||
if (value.includes('(e)')) {
|
||||
violations.push({
|
||||
message:
|
||||
'expected point médian "·" instead of "(e)" for inclusive writing',
|
||||
fixable: false, // suggestion-only
|
||||
})
|
||||
}
|
||||
return violations
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
hasSuggestions: true,
|
||||
docs: {
|
||||
description:
|
||||
'Enforce French typographic spacing in JSON string values (NNBSP before ? ! ;, NBSP before : and inside « », etc.).',
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
const violations = lint(original)
|
||||
if (violations.length === 0) return
|
||||
|
||||
const fixed = autoFix(original)
|
||||
const replacement = JSON.stringify(fixed)
|
||||
const inclusiveSuggestion = original.includes('(e)')
|
||||
? JSON.stringify(original.replace(/\(e\)/g, '·e'))
|
||||
: null
|
||||
|
||||
for (const v of violations) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
message: v.message,
|
||||
...(v.fixable && fixed !== original
|
||||
? {
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, replacement)
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
...(!v.fixable && inclusiveSuggestion
|
||||
? {
|
||||
suggest: [
|
||||
{
|
||||
desc: 'Replace "(e)" with "·e"',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(
|
||||
node.value,
|
||||
inclusiveSuggestion
|
||||
)
|
||||
},
|
||||
},
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,10 +1,4 @@
|
||||
const pkg = require('./package.json')
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
name: pkg.name,
|
||||
version: pkg.version,
|
||||
},
|
||||
rules: {
|
||||
'no-unnecessary-trans': require('./no-unnecessary-trans'),
|
||||
'prefer-kebab-url': require('./prefer-kebab-url'),
|
||||
@@ -13,13 +7,5 @@ module.exports = {
|
||||
'require-script-runner': require('./require-script-runner'),
|
||||
'require-vi-doMock-valid-path': require('./require-vi-doMock-valid-path'),
|
||||
'require-loading-label': require('./require-loading-label'),
|
||||
'require-cio-snake-case-properties': require('./require-cio-snake-case-properties'),
|
||||
'no-throw-in-callback': require('./no-throw-in-callback'),
|
||||
'no-consecutive-spaces-in-locales': require('./no-consecutive-spaces-in-locales'),
|
||||
'no-straight-apostrophes-in-locales': require('./no-straight-apostrophes-in-locales'),
|
||||
'french-typography-in-locales': require('./french-typography-in-locales'),
|
||||
'sorted-keys-in-locales': require('./sorted-keys-in-locales'),
|
||||
'locale-variables-match-en': require('./locale-variables-match-en'),
|
||||
'no-orphan-locale-keys': require('./no-orphan-locale-keys'),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,93 +0,0 @@
|
||||
// Ensure variable placeholders (`__name__`) in non-en locale values match
|
||||
// those in the corresponding en.json key.
|
||||
//
|
||||
// Apply this rule to all non-en locale JSON files.
|
||||
|
||||
const fs = require('node:fs')
|
||||
const Path = require('node:path')
|
||||
|
||||
// Placeholders always available in translations (injected by the i18n layer
|
||||
// or globally defined as defaults).
|
||||
const GLOBALS = new Set(['__appName__'])
|
||||
|
||||
// Keys whose translations are allowed to omit specific base placeholders.
|
||||
const IGNORE_NESTING_FOR = {
|
||||
over_x_templates_easy_getting_started: ['__templates__'],
|
||||
all_packages_and_templates: ['__templatesLink__'],
|
||||
}
|
||||
|
||||
function extractPlaceholders(str) {
|
||||
return Array.from(str.matchAll(/__.*?__/g), m => m[0])
|
||||
}
|
||||
|
||||
const enCache = new Map()
|
||||
function loadEn(localesDir) {
|
||||
if (enCache.has(localesDir)) return enCache.get(localesDir)
|
||||
const path = Path.join(localesDir, 'en.json')
|
||||
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
|
||||
enCache.set(localesDir, data)
|
||||
return data
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Ensure variable placeholders in non-en locale values match the en.json base.',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missing:
|
||||
'Translation for key "{{key}}" is missing placeholder(s) {{placeholders}} present in en.json.',
|
||||
extra:
|
||||
'Translation for key "{{key}}" has placeholder(s) {{placeholders}} not present in en.json.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename
|
||||
const localesDir = Path.dirname(filename)
|
||||
if (Path.basename(filename) === 'en.json') return {}
|
||||
|
||||
let base
|
||||
try {
|
||||
base = loadEn(localesDir)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const key = node.name.value
|
||||
if (!(key in base)) return // orphan key — covered by no-orphan-locale-keys
|
||||
|
||||
const basePlaceholders = extractPlaceholders(base[key])
|
||||
const targetPlaceholders = extractPlaceholders(node.value.value)
|
||||
const ignored = IGNORE_NESTING_FOR[key] || []
|
||||
|
||||
const missing = basePlaceholders.filter(
|
||||
p => !targetPlaceholders.includes(p) && !ignored.includes(p)
|
||||
)
|
||||
const extra = targetPlaceholders.filter(
|
||||
p => !basePlaceholders.includes(p) && !GLOBALS.has(p)
|
||||
)
|
||||
|
||||
if (missing.length > 0) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'missing',
|
||||
data: { key, placeholders: missing.join(', ') },
|
||||
})
|
||||
}
|
||||
if (extra.length > 0) {
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'extra',
|
||||
data: { key, placeholders: extra.join(', ') },
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
// Reject runs of two or more consecutive whitespace characters in JSON string
|
||||
// values. Catches ASCII spaces, NBSP, NNBSP, tabs, and other Unicode whitespace.
|
||||
|
||||
const CONSECUTIVE_WHITESPACE = /\s{2,}/
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow runs of two or more consecutive whitespace characters in JSON string values (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
consecutiveSpaces:
|
||||
'Locale value contains a run of consecutive whitespace. Collapse to a single space.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
if (!CONSECUTIVE_WHITESPACE.test(original)) return
|
||||
const fixed = original.replace(/\s{2,}/g, ' ')
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'consecutiveSpaces',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, JSON.stringify(fixed))
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,69 +0,0 @@
|
||||
// Detect translation keys in non-en locale files that don't exist in
|
||||
// en.json (orphans).
|
||||
|
||||
const fs = require('node:fs')
|
||||
const Path = require('node:path')
|
||||
|
||||
const enCache = new Map()
|
||||
function loadEnKeys(localesDir) {
|
||||
if (enCache.has(localesDir)) return enCache.get(localesDir)
|
||||
const path = Path.join(localesDir, 'en.json')
|
||||
const data = JSON.parse(fs.readFileSync(path, 'utf8'))
|
||||
const keys = new Set(Object.keys(data))
|
||||
enCache.set(localesDir, keys)
|
||||
return keys
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Detect translation keys in non-en locale files that are not present in en.json.',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
orphan:
|
||||
'Translation key "{{key}}" is not present in en.json (orphan key).',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const filename = context.filename
|
||||
const localesDir = Path.dirname(filename)
|
||||
if (Path.basename(filename) === 'en.json') return {}
|
||||
|
||||
let enKeys
|
||||
try {
|
||||
enKeys = loadEnKeys(localesDir)
|
||||
} catch {
|
||||
return {}
|
||||
}
|
||||
|
||||
return {
|
||||
'Document > Object'(node) {
|
||||
const orphans = node.members.filter(m => !enKeys.has(m.name.value))
|
||||
if (orphans.length === 0) return
|
||||
|
||||
const text = context.sourceCode.text
|
||||
const parsed = JSON.parse(text)
|
||||
for (const member of orphans) {
|
||||
delete parsed[member.name.value]
|
||||
}
|
||||
const sortedRemaining = Object.keys(parsed).sort()
|
||||
const cleaned = JSON.stringify(parsed, sortedRemaining, 2) + '\n'
|
||||
|
||||
for (const member of orphans) {
|
||||
context.report({
|
||||
node: member,
|
||||
messageId: 'orphan',
|
||||
data: { key: member.name.value },
|
||||
fix(fixer) {
|
||||
return fixer.replaceTextRange([0, text.length], cleaned)
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
// Reject straight apostrophes (') in JSON string values. Straight apostrophes
|
||||
// in Angular templates can lead to XSS; use the right single quotation mark
|
||||
// ’ (U+2019) instead. See https://github.com/overleaf/issues/issues/4478
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow straight apostrophes in JSON string values (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
straightApostrophe:
|
||||
"Locale value contains a straight apostrophe ('). Use the right single quotation mark ’ (U+2019) instead.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
Member(node) {
|
||||
if (node.value.type !== 'String') return
|
||||
const original = node.value.value
|
||||
if (!original.includes("'")) return
|
||||
const fixed = original.replace(/'/g, '’')
|
||||
context.report({
|
||||
node: node.value,
|
||||
messageId: 'straightApostrophe',
|
||||
fix(fixer) {
|
||||
return fixer.replaceText(node.value, JSON.stringify(fixed))
|
||||
},
|
||||
})
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
const CALLBACK_PARAM_NAMES = new Set(['cb', 'callback', 'done', 'next'])
|
||||
|
||||
function isCallbackParam(param) {
|
||||
return (
|
||||
param && param.type === 'Identifier' && CALLBACK_PARAM_NAMES.has(param.name)
|
||||
)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'error',
|
||||
docs: {
|
||||
description: 'Disallow throw statements inside callback-based functions',
|
||||
},
|
||||
messages: {
|
||||
noThrowInCallback:
|
||||
'Pass the error to the callback instead of throwing in callback-based code.',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
// Stack tracks whether each enclosing function is a callback-style function.
|
||||
// A callback-style function is non-async and has a last param named cb/callback/done/next.
|
||||
const stack = []
|
||||
|
||||
function enterFunction(node) {
|
||||
const params = node.params
|
||||
const isCallback =
|
||||
!node.async &&
|
||||
params.length > 0 &&
|
||||
isCallbackParam(params[params.length - 1])
|
||||
stack.push(isCallback)
|
||||
}
|
||||
|
||||
function exitFunction() {
|
||||
stack.pop()
|
||||
}
|
||||
|
||||
return {
|
||||
FunctionDeclaration: enterFunction,
|
||||
'FunctionDeclaration:exit': exitFunction,
|
||||
FunctionExpression: enterFunction,
|
||||
'FunctionExpression:exit': exitFunction,
|
||||
ArrowFunctionExpression: enterFunction,
|
||||
'ArrowFunctionExpression:exit': exitFunction,
|
||||
ThrowStatement(node) {
|
||||
if (stack[stack.length - 1]) {
|
||||
context.report({ node, messageId: 'noThrowInCallback' })
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -5,14 +5,11 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"lodash": "^4.18.1"
|
||||
"eslint": "^8.51.0",
|
||||
"lodash": "^4.17.21"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/json": "^2.0.0",
|
||||
"@typescript-eslint/parser": "^8.59.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"eslint": "^10.4.0"
|
||||
"@typescript-eslint/parser": "^8.50.0"
|
||||
},
|
||||
"scripts": {
|
||||
"test": "node rules.test.js"
|
||||
|
||||
@@ -49,7 +49,6 @@ const ignoreWords = {
|
||||
camel: new Set([
|
||||
'addWorkflowScope',
|
||||
'aiErrorAssistant',
|
||||
'aiFeatureUsage',
|
||||
'beginAuth',
|
||||
'brandVariationId',
|
||||
'closeEditor',
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const SNAKE_CASE_RE = /^[a-z][a-z0-9]*(_[a-z0-9]+)*$/
|
||||
|
||||
function isSnakeCase(name) {
|
||||
return SNAKE_CASE_RE.test(name)
|
||||
}
|
||||
|
||||
function getStaticKeyName(property) {
|
||||
if (property.computed) return null
|
||||
if (property.key.type === 'Identifier') return property.key.name
|
||||
if (property.key.type === 'Literal' && typeof property.key.value === 'string')
|
||||
return property.key.value
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a call to CustomerIoHandler.updateUserAttributes()
|
||||
* and return the attributes argument (2nd argument)
|
||||
*/
|
||||
function getUpdateUserAttributesArg(node) {
|
||||
if (
|
||||
node.callee.type === 'MemberExpression' &&
|
||||
node.callee.object.type === 'Identifier' &&
|
||||
node.callee.object.name === 'CustomerIoHandler' &&
|
||||
node.callee.property.name === 'updateUserAttributes' &&
|
||||
node.arguments[1]?.type === 'ObjectExpression'
|
||||
) {
|
||||
return node.arguments[1]
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a node is a call to Modules[.promises].hooks.fire('setUserProperties', ...)
|
||||
* and return the attributes argument (3rd argument)
|
||||
*/
|
||||
function getSetUserPropertiesArg(node) {
|
||||
const callee = node.callee
|
||||
if (callee.type !== 'MemberExpression' || callee.property.name !== 'fire') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Check first argument is 'setUserProperties'
|
||||
if (
|
||||
!node.arguments[0] ||
|
||||
node.arguments[0].type !== 'Literal' ||
|
||||
node.arguments[0].value !== 'setUserProperties'
|
||||
) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Match: Modules.hooks.fire or Modules.promises.hooks.fire
|
||||
const obj = callee.object
|
||||
if (obj.type === 'MemberExpression' && obj.property.name === 'hooks') {
|
||||
const parent = obj.object
|
||||
// Modules.hooks
|
||||
if (parent.type === 'Identifier' && parent.name === 'Modules') {
|
||||
if (node.arguments[2]?.type === 'ObjectExpression') {
|
||||
return node.arguments[2]
|
||||
}
|
||||
}
|
||||
// Modules.promises.hooks
|
||||
if (
|
||||
parent.type === 'MemberExpression' &&
|
||||
parent.property.name === 'promises' &&
|
||||
parent.object.type === 'Identifier' &&
|
||||
parent.object.name === 'Modules'
|
||||
) {
|
||||
if (node.arguments[2]?.type === 'ObjectExpression') {
|
||||
return node.arguments[2]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Enforce snake_case for Customer.io user property attribute names',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const attrsNode =
|
||||
getUpdateUserAttributesArg(node) || getSetUserPropertiesArg(node)
|
||||
if (!attrsNode) return
|
||||
|
||||
for (const property of attrsNode.properties) {
|
||||
if (property.type === 'SpreadElement') continue
|
||||
|
||||
const keyName = getStaticKeyName(property)
|
||||
if (keyName === null) continue // skip computed/dynamic keys
|
||||
|
||||
if (!isSnakeCase(keyName)) {
|
||||
context.report({
|
||||
node: property.key,
|
||||
message: `Customer.io attribute '{{name}}' must be in snake_case.`,
|
||||
data: { name: keyName },
|
||||
})
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -22,7 +22,7 @@ module.exports = {
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
const currentFilePath = context.filename
|
||||
const currentFilePath = context.getFilename()
|
||||
// ESLint can sometimes pass <text> or <input> for snippets not in a file
|
||||
if (currentFilePath === '<text>' || currentFilePath === '<input>') {
|
||||
return {}
|
||||
@@ -81,10 +81,9 @@ module.exports = {
|
||||
typeof firstArg.value !== 'string'
|
||||
) {
|
||||
if (firstArg.type === 'Identifier') {
|
||||
const scope = context.sourceCode.getScope(node)
|
||||
const variable = scope.variables.find(
|
||||
v => v.name === firstArg.name
|
||||
)
|
||||
const variable = context
|
||||
.getScope()
|
||||
.variables.find(v => v.name === firstArg.name)
|
||||
if (
|
||||
variable &&
|
||||
variable.defs.length > 0 &&
|
||||
|
||||
@@ -1,31 +1,18 @@
|
||||
const { RuleTester } = require('eslint')
|
||||
const tsParser = require('@typescript-eslint/parser')
|
||||
const json = require('@eslint/json').default
|
||||
const noThrowInCallback = require('./no-throw-in-callback')
|
||||
const preferKebabUrl = require('./prefer-kebab-url')
|
||||
const noUnnecessaryTrans = require('./no-unnecessary-trans')
|
||||
const shouldUnescapeTrans = require('./should-unescape-trans')
|
||||
const noGeneratedEditorThemes = require('./no-generated-editor-themes')
|
||||
const viDoMockValidPath = require('./require-vi-doMock-valid-path')
|
||||
const requireCioSnakeCaseProperties = require('./require-cio-snake-case-properties')
|
||||
const noConsecutiveSpacesInLocales = require('./no-consecutive-spaces-in-locales')
|
||||
const noStraightApostrophesInLocales = require('./no-straight-apostrophes-in-locales')
|
||||
const frenchTypographyInLocales = require('./french-typography-in-locales')
|
||||
const sortedKeysInLocales = require('./sorted-keys-in-locales')
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
parser: tsParser,
|
||||
parser: require.resolve('@typescript-eslint/parser'),
|
||||
parserOptions: {
|
||||
ecmaVersion: 'latest',
|
||||
parserOptions: { ecmaFeatures: { jsx: true } },
|
||||
ecmaFeatures: { jsx: true },
|
||||
},
|
||||
})
|
||||
|
||||
const jsonRuleTester = new RuleTester({
|
||||
plugins: { json },
|
||||
language: 'json/json',
|
||||
})
|
||||
|
||||
ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
||||
valid: [
|
||||
{ code: `app.get('/foo-bar')` },
|
||||
@@ -44,27 +31,19 @@ ruleTester.run('prefer-kebab-url', preferKebabUrl, {
|
||||
invalid: [
|
||||
{
|
||||
code: `app.get('/fooBar')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
||||
},
|
||||
{
|
||||
code: `app.get('/fooBar/:id')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
||||
},
|
||||
{
|
||||
code: `webRouter.get('/foo_bar/:id/FooBar/:name/fooBar')`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
||||
},
|
||||
{
|
||||
code: `router.get(/^\\/downLoad\\/pro-ject\\/([^/]*)\\/OutPut\\/out-put\\.pdf$/)`,
|
||||
errors: [
|
||||
{ message: 'Route path should be in kebab-case.', suggestions: 1 },
|
||||
],
|
||||
errors: [{ message: 'Route path should be in kebab-case.' }],
|
||||
},
|
||||
],
|
||||
})
|
||||
@@ -172,7 +151,6 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
||||
{
|
||||
message:
|
||||
'The path "./require-vi-doMock-valid-path2" in vi.doMock() cannot be resolved relative to the current file.',
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
@@ -183,271 +161,6 @@ ruleTester.run('domock-require-valid-path', viDoMockValidPath, {
|
||||
{
|
||||
message:
|
||||
'The first argument of vi.doMock() must be (or resolve to) a string literal representing a path.',
|
||||
suggestions: [],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
ruleTester.run(
|
||||
'require-cio-snake-case-properties',
|
||||
requireCioSnakeCaseProperties,
|
||||
{
|
||||
valid: [
|
||||
// updateUserAttributes with snake_case keys
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { plan_type: 'free', group_size: 10 })`,
|
||||
},
|
||||
// Modules.promises.hooks.fire with snake_case keys
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { plan_type: 'free', last_active: 123 })`,
|
||||
},
|
||||
// Modules.hooks.fire with snake_case keys
|
||||
{
|
||||
code: `Modules.hooks.fire('setUserProperties', userId, { plan_type: 'free' })`,
|
||||
},
|
||||
// Single-word keys are valid snake_case
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { email: 'a@b.com', role: 'admin' })`,
|
||||
},
|
||||
// Computed/dynamic keys are skipped
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { [dynamicKey]: true })`,
|
||||
},
|
||||
// Spread elements are skipped
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { ...existingAttrs })`,
|
||||
},
|
||||
// Unrelated function calls are not checked
|
||||
{
|
||||
code: `SomeOtherHandler.updateUserAttributes(userId, { camelCase: true })`,
|
||||
},
|
||||
// fire() with a different event name is not checked
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('someOtherEvent', userId, { camelCase: true })`,
|
||||
},
|
||||
],
|
||||
invalid: [
|
||||
// camelCase key in updateUserAttributes
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// kebab-case string key
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { 'plan-type': 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'plan-type' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// PascalCase key
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { PlanType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'PlanType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// camelCase in Modules.promises.hooks.fire
|
||||
{
|
||||
code: `Modules.promises.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// camelCase in Modules.hooks.fire
|
||||
{
|
||||
code: `Modules.hooks.fire('setUserProperties', userId, { planType: 'free' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
// Multiple invalid keys report multiple errors
|
||||
{
|
||||
code: `CustomerIoHandler.updateUserAttributes(userId, { planType: 'free', groupSize: 10, plan_term: 'annual' })`,
|
||||
errors: [
|
||||
{
|
||||
message: `Customer.io attribute 'planType' must be in snake_case.`,
|
||||
},
|
||||
{
|
||||
message: `Customer.io attribute 'groupSize' must be in snake_case.`,
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
const noThrowInCallbackMessage =
|
||||
'Pass the error to the callback instead of throwing in callback-based code.'
|
||||
ruleTester.run('no-throw-in-callback', noThrowInCallback, {
|
||||
valid: [
|
||||
// Calling the callback with an error is fine
|
||||
{ code: `function foo(cb) { cb(new Error()) }` },
|
||||
// async functions may throw (they return a rejected promise)
|
||||
{ code: `async function foo(cb) { throw new Error() }` },
|
||||
// Last param not a callback name — not a callback-style function
|
||||
{ code: `function foo(data) { throw new Error() }` },
|
||||
// No params at all
|
||||
{ code: `function foo() { throw new Error() }` },
|
||||
// throw inside a nested non-callback function is fine
|
||||
{ code: `function foo(cb) { [1].map(function() { throw new Error() }) }` },
|
||||
// throw inside a nested async arrow is fine
|
||||
{ code: `function foo(cb) { [1].map(async () => { throw new Error() }) }` },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `function foo(cb) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(callback) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(done) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(next) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `function foo(data, cb) { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
{
|
||||
code: `const foo = (cb) => { throw new Error() }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
// throw in a nested callback-style function inside another callback function
|
||||
{
|
||||
code: `function foo(cb) { bar(function(done) { throw new Error() }) }`,
|
||||
errors: [{ message: noThrowInCallbackMessage }],
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
jsonRuleTester.run(
|
||||
'no-consecutive-spaces-in-locales',
|
||||
noConsecutiveSpacesInLocales,
|
||||
{
|
||||
valid: [
|
||||
{ code: '{ "key": "one space" }' },
|
||||
{ code: '{ "key": "no whitespace" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{ "key": "two spaces" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "two spaces" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "three spaces" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "three spaces" }',
|
||||
},
|
||||
{
|
||||
// \t then space → two consecutive whitespace chars
|
||||
code: '{ "key": "tab\\t and" }',
|
||||
errors: [{ messageId: 'consecutiveSpaces' }],
|
||||
output: '{ "key": "tab and" }',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
jsonRuleTester.run(
|
||||
'no-straight-apostrophes-in-locales',
|
||||
noStraightApostrophesInLocales,
|
||||
{
|
||||
valid: [
|
||||
{ code: '{ "key": "no apostrophe" }' },
|
||||
{ code: '{ "key": "it’s curly" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: `{ "key": "it's straight" }`,
|
||||
errors: [{ messageId: 'straightApostrophe' }],
|
||||
output: '{ "key": "it’s straight" }',
|
||||
},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
jsonRuleTester.run('sorted-keys-in-locales', sortedKeysInLocales, {
|
||||
valid: [
|
||||
{ code: '{\n "a": "1",\n "b": "2"\n}\n' },
|
||||
{ code: '{}' },
|
||||
{ code: '{ "only": "one" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{\n "b": "2",\n "a": "1"\n}\n',
|
||||
errors: [{ messageId: 'unsorted' }],
|
||||
output: '{\n "a": "1",\n "b": "2"\n}\n',
|
||||
},
|
||||
],
|
||||
})
|
||||
|
||||
jsonRuleTester.run('french-typography-in-locales', frenchTypographyInLocales, {
|
||||
valid: [
|
||||
{ code: '{ "key": "Bonjour ?" }' },
|
||||
{ code: '{ "key": "Liste :" }' },
|
||||
{ code: '{ "key": "« contenu »" }' },
|
||||
{ code: '{ "key": "abc 123" }' },
|
||||
{ code: '{ "key": "10 %" }' },
|
||||
],
|
||||
invalid: [
|
||||
{
|
||||
code: '{ "key": "Bonjour?" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Bonjour ?" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "Bonjour ?" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Bonjour ?" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "Liste:" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "Liste :" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "«contenu»" }',
|
||||
errors: 2,
|
||||
output: '{ "key": "« contenu »" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "10%" }',
|
||||
errors: 1,
|
||||
output: '{ "key": "10 %" }',
|
||||
},
|
||||
{
|
||||
code: '{ "key": "sûr(e)" }',
|
||||
errors: [
|
||||
{
|
||||
message:
|
||||
'expected point médian "·" instead of "(e)" for inclusive writing',
|
||||
suggestions: [
|
||||
{
|
||||
desc: 'Replace "(e)" with "·e"',
|
||||
output: '{ "key": "sûr·e" }',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Enforce alphabetically sorted top-level keys in JSON files.
|
||||
|
||||
module.exports = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
fixable: 'code',
|
||||
docs: {
|
||||
description:
|
||||
'Require top-level keys to be sorted alphabetically in JSON files (typically locale files).',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
unsorted:
|
||||
'Top-level keys are not sorted alphabetically. Key "{{key}}" should come before "{{predecessor}}".',
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
return {
|
||||
'Document > Object'(node) {
|
||||
const members = node.members
|
||||
for (let i = 1; i < members.length; i++) {
|
||||
const prev = members[i - 1].name.value
|
||||
const curr = members[i].name.value
|
||||
if (curr < prev) {
|
||||
const text = context.sourceCode.text
|
||||
const parsed = JSON.parse(text)
|
||||
const sortedKeys = Object.keys(parsed).sort()
|
||||
const sorted = JSON.stringify(parsed, sortedKeys, 2) + '\n'
|
||||
context.report({
|
||||
node: members[i],
|
||||
messageId: 'unsorted',
|
||||
data: { key: curr, predecessor: prev },
|
||||
fix(fixer) {
|
||||
return fixer.replaceTextRange([0, text.length], sorted)
|
||||
},
|
||||
})
|
||||
return // one violation per file is enough; the fix re-sorts everything
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -4,7 +4,6 @@ fetch-utils
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/fetch-utils
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
|
||||
@@ -5,12 +5,6 @@ const fetch = require('node-fetch')
|
||||
const http = require('node:http')
|
||||
const https = require('node:https')
|
||||
|
||||
let logger
|
||||
|
||||
function setLogger(loggerInstance) {
|
||||
logger = loggerInstance
|
||||
}
|
||||
|
||||
/**
|
||||
* @import { Response } from 'node-fetch'
|
||||
*/
|
||||
@@ -29,7 +23,7 @@ async function fetchJson(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchJsonWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
fetchOpts.headers = fetchOpts.headers ?? {}
|
||||
fetchOpts.headers.Accept = fetchOpts.headers.Accept ?? 'application/json'
|
||||
|
||||
@@ -59,7 +53,7 @@ async function fetchStream(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchStreamWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, abortController, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts, abortController, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
|
||||
if (!response.ok) {
|
||||
@@ -82,7 +76,7 @@ async function fetchStreamWithResponse(url, opts = {}) {
|
||||
* @throws {RequestFailedError} if the response has a failure status code
|
||||
*/
|
||||
async function fetchNothing(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
@@ -114,7 +108,7 @@ async function fetchRedirect(url, opts = {}) {
|
||||
* @throws {RequestFailedError} if the response has a non redirect status code or missing Location header
|
||||
*/
|
||||
async function fetchRedirectWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
fetchOpts.redirect = 'manual'
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (response.status < 300 || response.status >= 400) {
|
||||
@@ -148,7 +142,7 @@ async function fetchString(url, opts = {}) {
|
||||
}
|
||||
|
||||
async function fetchStringWithResponse(url, opts = {}) {
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts, url)
|
||||
const { fetchOpts, detachSignal } = parseOpts(opts)
|
||||
const response = await performRequest(url, fetchOpts, detachSignal)
|
||||
if (!response.ok) {
|
||||
const body = await maybeGetResponseBody(response)
|
||||
@@ -173,7 +167,7 @@ class RequestFailedError extends OError {
|
||||
}
|
||||
}
|
||||
|
||||
function parseOpts(opts, url) {
|
||||
function parseOpts(opts) {
|
||||
const fetchOpts = _.omit(opts, ['json', 'signal', 'basicAuth'])
|
||||
if (opts.json) {
|
||||
setupJsonBody(fetchOpts, opts.json)
|
||||
@@ -184,30 +178,9 @@ function parseOpts(opts, url) {
|
||||
|
||||
const abortController = new AbortController()
|
||||
fetchOpts.signal = abortController.signal
|
||||
let detachSignal
|
||||
let detachSignal = () => {}
|
||||
if (opts.signal) {
|
||||
detachSignal = abortOnSignal(abortController, opts.signal)
|
||||
} else {
|
||||
let overTimeoutStart
|
||||
const stack = new Error().stack
|
||||
const timeout = setTimeout(() => {
|
||||
overTimeoutStart = process.hrtime.bigint()
|
||||
}, 120000)
|
||||
detachSignal = () => {
|
||||
clearTimeout(timeout)
|
||||
if (overTimeoutStart && logger) {
|
||||
logger.warn(
|
||||
{
|
||||
url,
|
||||
method: opts.method ?? 'GET',
|
||||
overTimeoutMs:
|
||||
Number(process.hrtime.bigint() - overTimeoutStart) / 1e6,
|
||||
stack,
|
||||
},
|
||||
'Fetch request did not complete within 120 seconds'
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (opts.body instanceof Readable) {
|
||||
abortOnDestroyedRequest(abortController, fetchOpts.body)
|
||||
@@ -350,5 +323,4 @@ module.exports = {
|
||||
ConnectTimeoutError,
|
||||
CustomHttpAgent,
|
||||
CustomHttpsAgent,
|
||||
setLogger,
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@
|
||||
"description": "utilities for node-fetch",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
@@ -22,12 +24,12 @@
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"selfsigned": "^5.5.0",
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"lodash": "^4.18.1",
|
||||
"node-fetch": "^2.7.0"
|
||||
"@overleaf/o-error": "*",
|
||||
"lodash": "^4.17.21",
|
||||
"node-fetch": "^2.7.0",
|
||||
"selfsigned": "^2.4.1"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -21,6 +21,12 @@ const {
|
||||
const HTTP_PORT = 30001
|
||||
const HTTPS_PORT = 30002
|
||||
|
||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||
const pems = selfsigned.generate(attrs, { days: 365 })
|
||||
|
||||
const PRIVATE_KEY = pems.private
|
||||
const PUBLIC_CERT = pems.cert
|
||||
|
||||
const dns = require('node:dns')
|
||||
const _originalLookup = dns.lookup
|
||||
// Custom DNS resolver function
|
||||
@@ -39,15 +45,8 @@ dns.lookup = (hostname, options, callback) => {
|
||||
}
|
||||
|
||||
describe('fetch-utils', function () {
|
||||
let PUBLIC_CERT
|
||||
|
||||
before(async function () {
|
||||
this.server = new TestServer()
|
||||
const attrs = [{ name: 'commonName', value: 'example.com' }]
|
||||
const pems = await selfsigned.generate(attrs, { days: 365, keySize: 2048 })
|
||||
|
||||
const PRIVATE_KEY = pems.private
|
||||
PUBLIC_CERT = pems.cert
|
||||
await this.server.start(HTTP_PORT, HTTPS_PORT, {
|
||||
key: PRIVATE_KEY,
|
||||
cert: PUBLIC_CERT,
|
||||
|
||||
@@ -119,20 +119,11 @@ class TestServer {
|
||||
}
|
||||
|
||||
stop() {
|
||||
const promises = []
|
||||
if (this.server) {
|
||||
const stopHttp = promisify(this.server.close).bind(this.server)
|
||||
this.server.closeAllConnections()
|
||||
promises.push(stopHttp())
|
||||
}
|
||||
if (this.https_server) {
|
||||
const stopHttps = promisify(this.https_server.close).bind(
|
||||
this.https_server
|
||||
)
|
||||
this.https_server.closeAllConnections()
|
||||
promises.push(stopHttps())
|
||||
}
|
||||
return Promise.all(promises)
|
||||
const stopHttp = promisify(this.server.close).bind(this.server)
|
||||
const stopHttps = promisify(this.https_server.close).bind(this.https_server)
|
||||
this.server.closeAllConnections()
|
||||
this.https_server.closeAllConnections()
|
||||
return Promise.all([stopHttp(), stopHttps()])
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
## v3.1.1
|
||||
|
||||
- Handle malformed requests in the req serializer
|
||||
* Handle malformed requests in the req serializer
|
||||
|
||||
## v3.0.0
|
||||
|
||||
- Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
|
||||
* Improve logging in Google Cloud Platform. Set environment variable `GCP_LOGGING=true` to enable.
|
||||
|
||||
This version of the metrics module only works with versions of the `@overleaf/metrics` module greater than v4.0.0
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ logger
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/logger
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
|
||||
@@ -6,7 +6,6 @@ const {
|
||||
FileLogLevelChecker,
|
||||
GCEMetadataLogLevelChecker,
|
||||
} = require('./log-level-checker')
|
||||
const { setLogger } = require('@overleaf/fetch-utils')
|
||||
|
||||
const LoggingManager = {
|
||||
/**
|
||||
@@ -32,7 +31,6 @@ const LoggingManager = {
|
||||
})
|
||||
this._setupRingBuffer()
|
||||
this._setupLogLevelChecker()
|
||||
setLogger(this)
|
||||
return this
|
||||
},
|
||||
|
||||
|
||||
@@ -10,17 +10,19 @@
|
||||
"license": "AGPL-3.0-only",
|
||||
"version": "3.1.1",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google-cloud/logging-bunyan": "^5.1.0",
|
||||
"@overleaf/fetch-utils": "workspace:*",
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"@overleaf/fetch-utils": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"bunyan": "^1.8.14"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -34,6 +36,6 @@
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@overleaf/metrics": "workspace:*"
|
||||
"@overleaf/metrics": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -80,9 +80,4 @@ function getRemoteIp(req) {
|
||||
return null
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
err: errSerializer,
|
||||
error: errSerializer,
|
||||
req: reqSerializer,
|
||||
res: resSerializer,
|
||||
}
|
||||
module.exports = { err: errSerializer, req: reqSerializer, res: resSerializer }
|
||||
|
||||
@@ -11,7 +11,7 @@ function testLogRecorder() {
|
||||
if (name in entry) entry[name] = fn(entry[name])
|
||||
}
|
||||
currentTest.consoleErrors = (currentTest.consoleErrors || []).concat(
|
||||
JSON.stringify(entry, bunyan.safeCycles())
|
||||
JSON.stringify(info, bunyan.safeCycles())
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,13 +43,9 @@ describe('LoggingManager', function () {
|
||||
.stub()
|
||||
.returns(this.GCEMetadataLogLevelChecker),
|
||||
}
|
||||
this.fetchUtils = {
|
||||
setLogger: sinon.stub(),
|
||||
}
|
||||
this.LoggingManager = SandboxedModule.require(MODULE_PATH, {
|
||||
requires: {
|
||||
bunyan: this.Bunyan,
|
||||
'@overleaf/fetch-utils': this.fetchUtils,
|
||||
'./log-level-checker': this.LogLevelChecker,
|
||||
},
|
||||
})
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
## v4.1.0
|
||||
|
||||
- Allows skipping the `sampleRate` argument.
|
||||
* Allows skipping the `sampleRate` argument.
|
||||
|
||||
## v4.0.0
|
||||
|
||||
- Send unmodified request and response to logger.
|
||||
* Send unmodified request and response to logger.
|
||||
|
||||
This version of the metrics module only works with versions of the `@overleaf/logger` module greater than v3.0.0
|
||||
|
||||
@@ -4,7 +4,6 @@ metrics
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/metrics
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
|
||||
@@ -75,4 +75,4 @@ class RequestLogger {
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { monitor, RequestLogger, getRoutePath }
|
||||
module.exports = { monitor, RequestLogger }
|
||||
|
||||
@@ -104,7 +104,7 @@ module.exports = MemoryMonitor = {
|
||||
},
|
||||
'global.gc() forced'
|
||||
)
|
||||
Metrics.timing('memory.gc-time', +gcTime)
|
||||
// Metrics.timing("memory.gc-time", gcTime)
|
||||
Metrics.gauge('memory.gc-rss-freed', -deltaMem.rss)
|
||||
Metrics.gauge('memory.gc-heaptotal-freed', -deltaMem.heapTotal)
|
||||
return Metrics.gauge('memory.gc-heapused-freed', -deltaMem.heapUsed)
|
||||
|
||||
@@ -1,106 +1,67 @@
|
||||
const { Gauge, Summary, Counter } = require('prom-client')
|
||||
const { Gauge, Summary } = require('prom-client')
|
||||
|
||||
/** @type {poolSize: Gauge<string>, availableConnections: Gauge<string>, waitQueueSize: Gauge<string>, maxPoolSize: Gauge<string>, mongoCommandStarted: Counter<string>, mongoCommandTimer: Summary<string>} */
|
||||
let metrics
|
||||
const collectPoolMetrics = []
|
||||
|
||||
/**
|
||||
* @param clientLabel
|
||||
*/
|
||||
function initMetricsOnce(clientLabel) {
|
||||
if (metrics) return
|
||||
const poolLabelNames = ['mongo_server']
|
||||
if (clientLabel) poolLabelNames.push('client')
|
||||
function monitor(mongoClient) {
|
||||
const labelNames = ['mongo_server']
|
||||
const poolSize = new Gauge({
|
||||
name: 'mongo_connection_pool_size',
|
||||
help: 'number of connections in the connection pool',
|
||||
labelNames: poolLabelNames,
|
||||
labelNames,
|
||||
// Use this one metric's collect() to set all metrics' values.
|
||||
collect() {
|
||||
// Reset all gauges in case they contain values for servers that
|
||||
// disappeared
|
||||
metrics.poolSize.reset()
|
||||
metrics.availableConnections.reset()
|
||||
metrics.waitQueueSize.reset()
|
||||
metrics.maxPoolSize.reset()
|
||||
collectPoolMetrics.forEach(fn => fn())
|
||||
},
|
||||
collect,
|
||||
})
|
||||
const availableConnections = new Gauge({
|
||||
name: 'mongo_connection_pool_available',
|
||||
help: 'number of connections that are not busy',
|
||||
labelNames: poolLabelNames,
|
||||
labelNames,
|
||||
})
|
||||
const waitQueueSize = new Gauge({
|
||||
name: 'mongo_connection_pool_waiting',
|
||||
help: 'number of operations waiting for an available connection',
|
||||
labelNames: poolLabelNames,
|
||||
labelNames,
|
||||
})
|
||||
const maxPoolSize = new Gauge({
|
||||
name: 'mongo_connection_pool_max',
|
||||
help: 'max size for the connection pool',
|
||||
labelNames: poolLabelNames,
|
||||
labelNames,
|
||||
})
|
||||
|
||||
const mongoCommandStarted = new Counter({
|
||||
name: 'mongo_command_started',
|
||||
help: 'mongo command started',
|
||||
labelNames: ['method', 'collection'],
|
||||
})
|
||||
const mongoCommandTimer = new Summary({
|
||||
name: 'mongo_command_time',
|
||||
help: 'time taken to complete a mongo command',
|
||||
percentiles: [],
|
||||
labelNames: ['status', 'method', 'ns'],
|
||||
labelNames: ['status', 'method'],
|
||||
})
|
||||
|
||||
metrics = {
|
||||
poolSize,
|
||||
availableConnections,
|
||||
waitQueueSize,
|
||||
maxPoolSize,
|
||||
mongoCommandStarted,
|
||||
mongoCommandTimer,
|
||||
}
|
||||
return metrics
|
||||
}
|
||||
|
||||
function monitor(mongoClient, clientLabel) {
|
||||
initMetricsOnce(clientLabel)
|
||||
|
||||
mongoClient.on('commandStarted', event => {
|
||||
const { commandName, command } = event
|
||||
const collection = command?.[commandName]
|
||||
if (typeof collection !== 'string') return // Lifecycle commands
|
||||
if (commandName === 'create') return // Mongoose init
|
||||
metrics.mongoCommandStarted.inc({
|
||||
method: commandName === 'find' ? 'read' : 'write',
|
||||
collection,
|
||||
if (mongoClient.on) {
|
||||
mongoClient.on('commandSucceeded', event => {
|
||||
mongoCommandTimer.observe(
|
||||
{
|
||||
status: 'success',
|
||||
method: event.commandName === 'find' ? 'read' : 'write',
|
||||
},
|
||||
event.duration
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
mongoClient.on('commandSucceeded', event => {
|
||||
metrics.mongoCommandTimer.observe(
|
||||
{
|
||||
status: 'success',
|
||||
method: event.commandName === 'find' ? 'read' : 'write',
|
||||
ns: event.reply?.cursor?.ns, // best effort, set on 'find'
|
||||
},
|
||||
event.duration
|
||||
)
|
||||
})
|
||||
|
||||
mongoClient.on('commandFailed', event => {
|
||||
metrics.mongoCommandTimer.observe(
|
||||
{
|
||||
status: 'failed',
|
||||
method: event.commandName === 'find' ? 'read' : 'write',
|
||||
},
|
||||
event.duration
|
||||
)
|
||||
})
|
||||
mongoClient.on('commandFailed', event => {
|
||||
mongoCommandTimer.observe(
|
||||
{
|
||||
status: 'failed',
|
||||
method: event.commandName === 'find' ? 'read' : 'write',
|
||||
},
|
||||
event.duration
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
function collect() {
|
||||
// Reset all gauges in case they contain values for servers that
|
||||
// disappeared
|
||||
poolSize.reset()
|
||||
availableConnections.reset()
|
||||
waitQueueSize.reset()
|
||||
maxPoolSize.reset()
|
||||
|
||||
const servers = mongoClient.topology?.s?.servers
|
||||
if (servers != null) {
|
||||
for (const [address, server] of servers) {
|
||||
@@ -111,21 +72,13 @@ function monitor(mongoClient, clientLabel) {
|
||||
}
|
||||
|
||||
const labels = { mongo_server: address }
|
||||
if (clientLabel) labels.client = clientLabel
|
||||
metrics.poolSize.set(labels, pool.totalConnectionCount)
|
||||
metrics.availableConnections.set(labels, pool.availableConnectionCount)
|
||||
metrics.waitQueueSize.set(labels, pool.waitQueueSize)
|
||||
metrics.maxPoolSize.set(labels, pool.options.maxPoolSize)
|
||||
poolSize.set(labels, pool.totalConnectionCount)
|
||||
availableConnections.set(labels, pool.availableConnectionCount)
|
||||
waitQueueSize.set(labels, pool.waitQueueSize)
|
||||
maxPoolSize.set(labels, pool.options.maxPoolSize)
|
||||
}
|
||||
}
|
||||
}
|
||||
collectPoolMetrics.push(collect)
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
monitor,
|
||||
reset() {
|
||||
metrics = undefined
|
||||
collectPoolMetrics.length = 0
|
||||
},
|
||||
}
|
||||
module.exports = { monitor }
|
||||
|
||||
@@ -8,14 +8,14 @@
|
||||
},
|
||||
"main": "index.js",
|
||||
"dependencies": {
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^3.0.0",
|
||||
"@google-cloud/profiler": "^6.0.4",
|
||||
"@opentelemetry/api": "^1.9.1",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.76.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.218.0",
|
||||
"@opentelemetry/resources": "^2.7.1",
|
||||
"@opentelemetry/sdk-node": "^0.218.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.41.1",
|
||||
"@google-cloud/opentelemetry-cloud-trace-exporter": "^2.1.0",
|
||||
"@google-cloud/profiler": "^6.0.3",
|
||||
"@opentelemetry/api": "^1.4.1",
|
||||
"@opentelemetry/auto-instrumentations-node": "^0.39.1",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.41.2",
|
||||
"@opentelemetry/resources": "^1.15.2",
|
||||
"@opentelemetry/sdk-node": "^0.41.2",
|
||||
"@opentelemetry/semantic-conventions": "^1.15.2",
|
||||
"compression": "^1.7.4",
|
||||
"prom-client": "^14.1.1",
|
||||
"yn": "^3.1.1"
|
||||
@@ -31,15 +31,17 @@
|
||||
"typescript": "^5.0.4"
|
||||
},
|
||||
"scripts": {
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"test:acceptance": "mocha --recursive --exit --grep=${MOCHA_GREP:-} test/acceptance",
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"test:acceptance": "mocha --recursive --exit --grep=$MOCHA_GREP test/acceptance",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@overleaf/logger": "workspace:*"
|
||||
"@overleaf/logger": "*"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,15 +14,11 @@ describe('mongodb', function () {
|
||||
}
|
||||
this.servers = new Map([['server1', { s: { pool: this.pool } }]])
|
||||
|
||||
this.mongoClient = {
|
||||
on() {},
|
||||
topology: { s: { servers: this.servers } },
|
||||
}
|
||||
Metrics.mongodb.reset()
|
||||
this.mongoClient = { topology: { s: { servers: this.servers } } }
|
||||
})
|
||||
|
||||
it('handles an unconnected client', async function () {
|
||||
const mongoClient = { on() {} }
|
||||
const mongoClient = {}
|
||||
Metrics.mongodb.monitor(mongoClient)
|
||||
const metrics = await getMetrics()
|
||||
expect(metrics).to.deep.equal({})
|
||||
@@ -39,17 +35,6 @@ describe('mongodb', function () {
|
||||
})
|
||||
})
|
||||
|
||||
it('collects Mongo metrics with client', async function () {
|
||||
Metrics.mongodb.monitor(this.mongoClient, 'native')
|
||||
const metrics = await getMetrics()
|
||||
expect(metrics).to.deep.equal({
|
||||
'mongo_connection_pool_max:server1:native': 10,
|
||||
'mongo_connection_pool_size:server1:native': 8,
|
||||
'mongo_connection_pool_available:server1:native': 2,
|
||||
'mongo_connection_pool_waiting:server1:native': 4,
|
||||
})
|
||||
})
|
||||
|
||||
it('handles topology changes', async function () {
|
||||
Metrics.mongodb.monitor(this.mongoClient)
|
||||
let metrics = await getMetrics()
|
||||
@@ -96,10 +81,7 @@ async function getMetrics() {
|
||||
const result = {}
|
||||
for (const metric of metrics) {
|
||||
for (const value of metric.values) {
|
||||
let key = `${metric.name}:${value.labels.mongo_server}`
|
||||
if (value.labels.client) {
|
||||
key = `${key}:${value.labels.client}`
|
||||
}
|
||||
const key = `${metric.name}:${value.labels.mongo_server}`
|
||||
result[key] = value.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -8,20 +8,13 @@ const READ_PREFERENCE_SECONDARY =
|
||||
: ReadPreference.secondaryPreferred.mode
|
||||
|
||||
const ONE_MONTH_IN_MS = 1000 * 60 * 60 * 24 * 31
|
||||
/** @type {ObjectId | null} */
|
||||
let ID_EDGE_PAST
|
||||
const ID_EDGE_FUTURE = objectIdFromMs(Date.now() + 1000)
|
||||
/** @type {boolean} */
|
||||
let BATCH_DESCENDING
|
||||
/** @type {number} */
|
||||
let BATCH_SIZE
|
||||
/** @type {boolean} */
|
||||
let VERBOSE_LOGGING
|
||||
/** @type {ObjectId} */
|
||||
let BATCH_RANGE_START
|
||||
/** @type {ObjectId} */
|
||||
let BATCH_RANGE_END
|
||||
/** @type {number} */
|
||||
let BATCH_MAX_TIME_SPAN_IN_MS
|
||||
let BATCHED_UPDATE_RUNNING = false
|
||||
|
||||
@@ -62,7 +55,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
|
||||
if (BATCH_DESCENDING) {
|
||||
BATCH_RANGE_START = ID_EDGE_FUTURE
|
||||
} else {
|
||||
BATCH_RANGE_START = /** @type {ObjectId} */ (ID_EDGE_PAST)
|
||||
BATCH_RANGE_START = ID_EDGE_PAST
|
||||
}
|
||||
}
|
||||
BATCH_MAX_TIME_SPAN_IN_MS = parseInt(
|
||||
@@ -73,7 +66,7 @@ function refreshGlobalOptionsForBatchedUpdate(options = {}) {
|
||||
BATCH_RANGE_END = objectIdFromInput(options.BATCH_RANGE_END)
|
||||
} else {
|
||||
if (BATCH_DESCENDING) {
|
||||
BATCH_RANGE_END = /** @type {ObjectId} */ (ID_EDGE_PAST)
|
||||
BATCH_RANGE_END = ID_EDGE_PAST
|
||||
} else {
|
||||
BATCH_RANGE_END = ID_EDGE_FUTURE
|
||||
}
|
||||
|
||||
@@ -4,8 +4,6 @@ mongo-utils
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/mongo-utils
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
--tsconfig-no-implicit-any=True
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
"description": "utilities to help working with mongo",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"author": "Overleaf (https://www.overleaf.com)",
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// @ts-check
|
||||
|
||||
/**
|
||||
* @typedef {import('mongodb').MongoClient} MongoClient
|
||||
* @typedef {import('mongodb-legacy').MongoClient} LegacyMongoClient
|
||||
* @import { MongoClient } from 'mongodb'
|
||||
* @import { MongoClient as LegacyMongoClient } from 'mongodb-legacy'
|
||||
*/
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": true
|
||||
},
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
+53
-67
@@ -16,23 +16,23 @@ Light-weight helpers for handling JavaScript Errors in node.js and the browser.
|
||||
<!-- toc -->
|
||||
|
||||
- [Long Stack Traces with `OError.tag`](#long-stack-traces-with-oerrortag)
|
||||
- [The Problem](#the-problem)
|
||||
- [The Solution](#the-solution)
|
||||
- [Adding More Info](#adding-more-info)
|
||||
- [`async`/`await`](#asyncawait)
|
||||
- [Better Async Stack Traces in Node 12+](#better-async-stack-traces-in-node-12)
|
||||
- [Caveat: Shared Error Instances](#caveat-shared-error-instances)
|
||||
* [The Problem](#the-problem)
|
||||
* [The Solution](#the-solution)
|
||||
* [Adding More Info](#adding-more-info)
|
||||
* [`async`/`await`](#asyncawait)
|
||||
* [Better Async Stack Traces in Node 12+](#better-async-stack-traces-in-node-12)
|
||||
* [Caveat: Shared Error Instances](#caveat-shared-error-instances)
|
||||
- [Create Custom Error Classes](#create-custom-error-classes)
|
||||
- [Attaching Extra Info](#attaching-extra-info)
|
||||
- [Wrapping an Internal Error](#wrapping-an-internal-error)
|
||||
* [Attaching Extra Info](#attaching-extra-info)
|
||||
* [Wrapping an Internal Error](#wrapping-an-internal-error)
|
||||
- [OError API Reference](#oerror-api-reference)
|
||||
- [new OError(message, [info], [cause])](#new-oerrormessage-info-cause)
|
||||
- [oError.withInfo(info) ⇒ this](#oerrorwithinfoinfo--this)
|
||||
- [oError.withCause(cause) ⇒ this](#oerrorwithcausecause--this)
|
||||
- [OError.maxTags : Number](#oerrormaxtags--number)
|
||||
- [OError.tag(error, [message], [info]) ⇒ Error](#oerrortagerror-message-info--error)
|
||||
- [OError.getFullInfo(error) ⇒ Object](#oerrorgetfullinfoerror--object)
|
||||
- [OError.getFullStack(error) ⇒ string](#oerrorgetfullstackerror--string)
|
||||
* [new OError(message, [info], [cause])](#new-oerrormessage-info-cause)
|
||||
* [oError.withInfo(info) ⇒ this](#oerrorwithinfoinfo--this)
|
||||
* [oError.withCause(cause) ⇒ this](#oerrorwithcausecause--this)
|
||||
* [OError.maxTags : Number](#oerrormaxtags--number)
|
||||
* [OError.tag(error, [message], [info]) ⇒ Error](#oerrortagerror-message-info--error)
|
||||
* [OError.getFullInfo(error) ⇒ Object](#oerrorgetfullinfoerror--object)
|
||||
* [OError.getFullStack(error) ⇒ string](#oerrorgetfullstackerror--string)
|
||||
- [References](#references)
|
||||
|
||||
<!-- tocstop -->
|
||||
@@ -345,56 +345,52 @@ caused by:
|
||||
## OError API Reference
|
||||
|
||||
<a name="OError"></a>
|
||||
|
||||
- [OError](#OError)
|
||||
- [new OError(message, [info], [cause])](#new_OError_new)
|
||||
- _instance_
|
||||
- [.withInfo(info)](#OError+withInfo) ⇒ <code>this</code>
|
||||
- [.withCause(cause)](#OError+withCause) ⇒ <code>this</code>
|
||||
- _static_
|
||||
- [.maxTags](#OError.maxTags) : <code>Number</code>
|
||||
- [.tag(error, [message], [info])](#OError.tag) ⇒ <code>Error</code>
|
||||
- [.getFullInfo(error)](#OError.getFullInfo) ⇒ <code>Object</code>
|
||||
- [.getFullStack(error)](#OError.getFullStack) ⇒ <code>string</code>
|
||||
* [OError](#OError)
|
||||
* [new OError(message, [info], [cause])](#new_OError_new)
|
||||
* _instance_
|
||||
* [.withInfo(info)](#OError+withInfo) ⇒ <code>this</code>
|
||||
* [.withCause(cause)](#OError+withCause) ⇒ <code>this</code>
|
||||
* _static_
|
||||
* [.maxTags](#OError.maxTags) : <code>Number</code>
|
||||
* [.tag(error, [message], [info])](#OError.tag) ⇒ <code>Error</code>
|
||||
* [.getFullInfo(error)](#OError.getFullInfo) ⇒ <code>Object</code>
|
||||
* [.getFullStack(error)](#OError.getFullStack) ⇒ <code>string</code>
|
||||
|
||||
<a name="new_OError_new"></a>
|
||||
|
||||
### new OError(message, [info], [cause])
|
||||
|
||||
| Param | Type | Description |
|
||||
| ------- | ------------------- | ----------------------------------------- |
|
||||
| message | <code>string</code> | as for built-in Error |
|
||||
| [info] | <code>Object</code> | extra data to attach to the error |
|
||||
| [cause] | <code>Error</code> | the internal error that caused this error |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| message | <code>string</code> | as for built-in Error |
|
||||
| [info] | <code>Object</code> | extra data to attach to the error |
|
||||
| [cause] | <code>Error</code> | the internal error that caused this error |
|
||||
|
||||
<a name="OError+withInfo"></a>
|
||||
|
||||
### oError.withInfo(info) ⇒ <code>this</code>
|
||||
|
||||
Set the extra info object for this error.
|
||||
|
||||
**Kind**: instance method of [<code>OError</code>](#OError)
|
||||
**Kind**: instance method of [<code>OError</code>](#OError)
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ------------------- | --------------------------------- |
|
||||
| info | <code>Object</code> | extra data to attach to the error |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| info | <code>Object</code> | extra data to attach to the error |
|
||||
|
||||
<a name="OError+withCause"></a>
|
||||
|
||||
### oError.withCause(cause) ⇒ <code>this</code>
|
||||
|
||||
Wrap the given error, which caused this error.
|
||||
|
||||
**Kind**: instance method of [<code>OError</code>](#OError)
|
||||
**Kind**: instance method of [<code>OError</code>](#OError)
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ------------------ | ----------------------------------------- |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| cause | <code>Error</code> | the internal error that caused this error |
|
||||
|
||||
<a name="OError.maxTags"></a>
|
||||
|
||||
### OError.maxTags : <code>Number</code>
|
||||
|
||||
Maximum number of tags to apply to any one error instance. This is to avoid
|
||||
a resource leak in the (hopefully unlikely) case that a singleton error
|
||||
instance is returned to many callbacks. If tags have been dropped, the full
|
||||
@@ -406,21 +402,19 @@ Defaults to 100. Must be at least 1.
|
||||
<a name="OError.tag"></a>
|
||||
|
||||
### OError.tag(error, [message], [info]) ⇒ <code>Error</code>
|
||||
|
||||
Tag debugging information onto any error (whether an OError or not) and
|
||||
return it.
|
||||
|
||||
**Kind**: static method of [<code>OError</code>](#OError)
|
||||
**Returns**: <code>Error</code> - the modified `error` argument
|
||||
**Returns**: <code>Error</code> - the modified `error` argument
|
||||
|
||||
| Param | Type | Description |
|
||||
| --------- | ------------------- | ----------------------------------- |
|
||||
| error | <code>Error</code> | the error to tag |
|
||||
| [message] | <code>string</code> | message with which to tag `error` |
|
||||
| [info] | <code>Object</code> | extra data with wich to tag `error` |
|
||||
|
||||
**Example** _(An error in a callback)_
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| error | <code>Error</code> | the error to tag |
|
||||
| [message] | <code>string</code> | message with which to tag `error` |
|
||||
| [info] | <code>Object</code> | extra data with wich to tag `error` |
|
||||
|
||||
**Example** *(An error in a callback)*
|
||||
```js
|
||||
function findUser(name, callback) {
|
||||
fs.readFile('/etc/passwd', (err, data) => {
|
||||
@@ -429,17 +423,13 @@ function findUser(name, callback) {
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Example** _(A possible error in a callback)_
|
||||
|
||||
**Example** *(A possible error in a callback)*
|
||||
```js
|
||||
function cleanup(callback) {
|
||||
fs.unlink('/tmp/scratch', err => callback(err && OError.tag(err)))
|
||||
fs.unlink('/tmp/scratch', (err) => callback(err && OError.tag(err)))
|
||||
}
|
||||
```
|
||||
|
||||
**Example** _(An error with async/await)_
|
||||
|
||||
**Example** *(An error with async/await)*
|
||||
```js
|
||||
async function cleanup() {
|
||||
try {
|
||||
@@ -449,34 +439,30 @@ async function cleanup() {
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
<a name="OError.getFullInfo"></a>
|
||||
|
||||
### OError.getFullInfo(error) ⇒ <code>Object</code>
|
||||
|
||||
The merged info from any `tag`s and causes on the given error.
|
||||
|
||||
If an info property is repeated, the last one wins.
|
||||
|
||||
**Kind**: static method of [<code>OError</code>](#OError)
|
||||
**Kind**: static method of [<code>OError</code>](#OError)
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ----------------------------------------------------------------- | ----------------------------------------- |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| error | <code>Error</code> \| <code>null</code> \| <code>undefined</code> | any error (may or may not be an `OError`) |
|
||||
|
||||
<a name="OError.getFullStack"></a>
|
||||
|
||||
### OError.getFullStack(error) ⇒ <code>string</code>
|
||||
|
||||
Return the `stack` property from `error`, including the `stack`s for any
|
||||
tagged errors added with `OError.tag` and for any `cause`s.
|
||||
|
||||
**Kind**: static method of [<code>OError</code>](#OError)
|
||||
**Kind**: static method of [<code>OError</code>](#OError)
|
||||
|
||||
| Param | Type | Description |
|
||||
| ----- | ----------------------------------------------------------------- | ----------------------------------------- |
|
||||
| Param | Type | Description |
|
||||
| --- | --- | --- |
|
||||
| error | <code>Error</code> \| <code>null</code> \| <code>undefined</code> | any error (may or may not be an `OError`) |
|
||||
|
||||
<!-- END API REFERENCE -->
|
||||
|
||||
## References
|
||||
|
||||
@@ -4,7 +4,6 @@ o-error
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/o-error
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
|
||||
@@ -17,11 +17,13 @@
|
||||
"index.cjs"
|
||||
],
|
||||
"scripts": {
|
||||
"build": "yarn run --silent test",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"build": "npm run --silent test",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
@@ -30,7 +32,7 @@
|
||||
"repository": "github:overleaf/o-error",
|
||||
"devDependencies": {
|
||||
"@types/chai": "^4.3.0",
|
||||
"@types/node": "^24.5.2",
|
||||
"@types/node": "^18.19.123",
|
||||
"chai": "^4.3.6",
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
node_modules
|
||||
@@ -46,9 +46,9 @@ Uploads a stream to the backend.
|
||||
- `key`: The key for the uploaded object
|
||||
- `readStream`: The data stream to upload
|
||||
- `opts` (optional):
|
||||
- `sourceMd5`: The md5 hash of the source data, if known. The uploaded data will be compared against this and the operation will fail if it does not match. If omitted, the md5 is calculated as the data is uploaded instead, and verified against the backend. This is not supported in `S3Persistor` as it performs [its own integrity protections](https://aws.amazon.com/blogs/aws/introducing-default-data-integrity-protections-for-new-objects-in-amazon-s3/). Setting `sourceMd5` with `S3Persistor` will result in an error being thrown.
|
||||
- `contentType`: The content type to write in the object metadata
|
||||
- `contentEncoding`: The content encoding to write in the object metadata
|
||||
- `sourceMd5`: The md5 hash of the source data, if known. The uploaded data will be compared against this and the operation will fail if it does not match. If omitted, the md5 is calculated as the data is uploaded instead, and verified against the backend. This is not supported in `S3Persistor` as it performs [its own integrity protections](https://aws.amazon.com/blogs/aws/introducing-default-data-integrity-protections-for-new-objects-in-amazon-s3/). Setting `sourceMd5` with `S3Persistor` will result in an error being thrown.
|
||||
- `contentType`: The content type to write in the object metadata
|
||||
- `contentEncoding`: The content encoding to write in the object metadata
|
||||
|
||||
##### Notes
|
||||
|
||||
@@ -304,7 +304,7 @@ In order to prevent accidental deletion from outside this mechanism, an event-ba
|
||||
Contributions should pass lint, formatting and unit test checks. To run these, use
|
||||
|
||||
```
|
||||
yarn run test
|
||||
npm run test
|
||||
```
|
||||
|
||||
There are no acceptance tests in this module, but https://github.com/overleaf/filestore/ contains a comprehensive set of acceptance tests that use this module. These should also pass, with the changes.
|
||||
|
||||
@@ -4,8 +4,6 @@ object-persistor
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=@overleaf/object-persistor
|
||||
--pipeline-owner=32
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🚉 Platform
|
||||
--public-repo=False
|
||||
--tsconfig-no-implicit-any=True
|
||||
|
||||
@@ -4,11 +4,13 @@
|
||||
"description": "Module for storing objects in multiple backends, with fallback on 404 to assist migration between them",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "yarn run lint && yarn run types:check && yarn run test:unit",
|
||||
"test": "npm run lint && npm run format && npm run types:check && npm run test:unit",
|
||||
"test:unit": "mocha --exit test/**/*.{js,cjs}",
|
||||
"lint": "eslint --cache --cache-location ../../.cache/eslint/ --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --cache --cache-location ../../.cache/eslint/ --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"test:ci": "yarn run test:unit",
|
||||
"lint": "eslint --ext .cjs,.js,.jsx,.mjs,.ts --max-warnings 0 --format unix .",
|
||||
"lint:fix": "eslint --fix --ext .cjs,.js,.jsx,.mjs,.ts .",
|
||||
"format": "prettier --list-different $PWD/'**/*.{js,cjs,ts}'",
|
||||
"format:fix": "prettier --write $PWD/'**/*.{js,cjs,ts}'",
|
||||
"test:ci": "npm run test:unit",
|
||||
"types:check": "tsc --noEmit"
|
||||
},
|
||||
"repository": {
|
||||
@@ -18,15 +20,15 @@
|
||||
"author": "Overleaf (https://www.overleaf.com/)",
|
||||
"license": "AGPL-3.0",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.994.0",
|
||||
"@aws-sdk/lib-storage": "^3.994.0",
|
||||
"@aws-sdk/node-http-handler": "^3.374.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.994.0",
|
||||
"@google-cloud/storage": "^7.19.0",
|
||||
"@overleaf/logger": "workspace:*",
|
||||
"@overleaf/metrics": "workspace:*",
|
||||
"@overleaf/o-error": "workspace:*",
|
||||
"@overleaf/stream-utils": "workspace:*",
|
||||
"@aws-sdk/client-s3": "^3.864.0",
|
||||
"@aws-sdk/lib-storage": "^3.864.0",
|
||||
"@aws-sdk/node-http-handler": "^3.370.0",
|
||||
"@aws-sdk/s3-request-presigner": "^3.864.0",
|
||||
"@google-cloud/storage": "^6.10.1",
|
||||
"@overleaf/logger": "*",
|
||||
"@overleaf/metrics": "*",
|
||||
"@overleaf/o-error": "*",
|
||||
"@overleaf/stream-utils": "*",
|
||||
"fast-crc32c": "overleaf/node-fast-crc32c#aae6b2a4c7a7a159395df9cc6c38dfde702d6f51",
|
||||
"glob": "^12.0.0",
|
||||
"range-parser": "^1.2.1",
|
||||
@@ -38,6 +40,7 @@
|
||||
"mocha": "^11.1.0",
|
||||
"mocha-junit-reporter": "^2.2.1",
|
||||
"mocha-multi-reporters": "^1.5.1",
|
||||
"mock-fs": "^5.2.0",
|
||||
"mongodb": "6.12.0",
|
||||
"sandboxed-module": "^2.0.4",
|
||||
"sinon": "^9.2.4",
|
||||
|
||||
@@ -3,12 +3,9 @@ const fs = require('node:fs')
|
||||
const fsPromises = require('node:fs/promises')
|
||||
const { glob } = require('glob')
|
||||
const Path = require('node:path')
|
||||
const { promisify } = require('node:util')
|
||||
const { PassThrough } = require('node:stream')
|
||||
const { pipeline } = require('node:stream/promises')
|
||||
|
||||
const openCb = promisify(fs.open)
|
||||
|
||||
const AbstractPersistor = require('./AbstractPersistor')
|
||||
const { ReadError, WriteError, NotImplementedError } = require('./Errors')
|
||||
const PersistorHelper = require('./PersistorHelper')
|
||||
@@ -88,9 +85,8 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
||||
})
|
||||
const fsPath = this._getFsPath(location, name, opts.useSubdirectories)
|
||||
|
||||
let fd
|
||||
try {
|
||||
fd = await openCb(fsPath, 'r')
|
||||
opts.fd = await fsPromises.open(fsPath, 'r')
|
||||
} catch (err) {
|
||||
throw PersistorHelper.wrapError(
|
||||
err,
|
||||
@@ -100,7 +96,7 @@ module.exports = class FSPersistor extends AbstractPersistor {
|
||||
)
|
||||
}
|
||||
|
||||
const stream = fs.createReadStream(null, { ...opts, fd })
|
||||
const stream = fs.createReadStream(null, opts)
|
||||
// Return a PassThrough stream with a minimal interface. It will buffer until the caller starts reading. It will emit errors from the source stream (Stream.pipeline passes errors along).
|
||||
const pass = new PassThrough()
|
||||
pipeline(stream, observer, pass).catch(() => {})
|
||||
|
||||
@@ -21,7 +21,7 @@ const hkdf = promisify(Crypto.hkdf)
|
||||
const AES256_KEY_LENGTH = 32
|
||||
|
||||
/**
|
||||
* @typedef {Object} EncryptionSettings
|
||||
* @typedef {Object} Settings
|
||||
* @property {boolean} automaticallyRotateDEKEncryption
|
||||
* @property {string} dataEncryptionKeyBucketName
|
||||
* @property {boolean} ignoreErrorsFromDEKReEncryption
|
||||
@@ -29,26 +29,6 @@ const AES256_KEY_LENGTH = 32
|
||||
* @property {() => Promise<Array<RootKeyEncryptionKey>>} getRootKeyEncryptionKeys
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} S3PersistorSettings
|
||||
* @property {Object<string, import('@aws-sdk/client-s3').StorageClass>} [storageClass]
|
||||
* @property {string} [key]
|
||||
* @property {string} [secret]
|
||||
* @property {string} [region]
|
||||
* @property {string} [endpoint]
|
||||
* @property {boolean} [pathStyle]
|
||||
* @property {number} [maxRetries]
|
||||
* @property {Object} [httpOptions]
|
||||
* @property {string} [ca]
|
||||
* @property {number} [signedUrlExpiryInMs]
|
||||
* @property {number} [partSize]
|
||||
* @property {Object<string, {auth_key: string, auth_secret: string}>} [bucketCreds]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {S3PersistorSettings & EncryptionSettings} Settings
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {import('./types').ListDirectoryResult} ListDirectoryResult
|
||||
*/
|
||||
@@ -125,6 +105,10 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
})
|
||||
}
|
||||
|
||||
async ensureKeyEncryptionKeysLoaded() {
|
||||
await this.#availableKeyEncryptionKeysPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
@@ -305,13 +289,6 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {NodeJS.ReadableStream} sourceStream
|
||||
* @param {import('./S3Persistor.js').StreamOptions} opts
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendStream(bucketName, path, sourceStream, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
@@ -322,13 +299,6 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<NodeJS.ReadableStream>}
|
||||
*/
|
||||
async getObjectStream(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
@@ -339,13 +309,6 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async getObjectSize(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
@@ -353,13 +316,6 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
return await super.getObjectSize(bucketName, path, { ...opts, ssecOptions })
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<string | undefined>}
|
||||
*/
|
||||
async getObjectStorageClass(bucketName, path, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
@@ -370,23 +326,11 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<number>}
|
||||
*/
|
||||
async directorySize(bucketName, path, continuationToken) {
|
||||
// Note: Listing a bucket does not require SSE-C credentials.
|
||||
return await super.directorySize(bucketName, path, continuationToken)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {string} [continuationToken]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async deleteDirectory(bucketName, path, continuationToken) {
|
||||
// Let [Settings.pathToProjectFolder] validate the project path before deleting things.
|
||||
const { projectFolder, dekPath } = this.#buildProjectPaths(bucketName, path)
|
||||
@@ -400,27 +344,12 @@ class PerProjectEncryptedS3Persistor extends S3Persistor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} path
|
||||
* @param {Object} opts
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getObjectMd5Hash(bucketName, path, opts = {}) {
|
||||
// The ETag in object metadata is not the MD5 content hash, skip the HEAD request.
|
||||
opts = { ...opts, etagIsNotMD5: true }
|
||||
return await super.getObjectMd5Hash(bucketName, path, opts)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {string} sourcePath
|
||||
* @param {string} destinationPath
|
||||
* @param {Object} opts
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @param {SSECOptions} [opts.ssecSrcOptions]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async copyObject(bucketName, sourcePath, destinationPath, opts = {}) {
|
||||
const ssecOptions =
|
||||
opts.ssecOptions ||
|
||||
|
||||
@@ -1,30 +1,27 @@
|
||||
const Logger = require('@overleaf/logger')
|
||||
const { SettingsError } = require('./Errors')
|
||||
const GcsPersistor = require('./GcsPersistor')
|
||||
const { S3Persistor } = require('./S3Persistor')
|
||||
const FSPersistor = require('./FSPersistor')
|
||||
const MigrationPersistor = require('./MigrationPersistor')
|
||||
const {
|
||||
PerProjectEncryptedS3Persistor,
|
||||
} = require('./PerProjectEncryptedS3Persistor')
|
||||
|
||||
function getPersistor(backend, settings) {
|
||||
switch (backend) {
|
||||
case 'aws-sdk':
|
||||
case 's3': {
|
||||
const { S3Persistor } = require('./S3Persistor')
|
||||
case 's3':
|
||||
return new S3Persistor(settings.s3)
|
||||
}
|
||||
case 's3SSEC': {
|
||||
const {
|
||||
PerProjectEncryptedS3Persistor,
|
||||
} = require('./PerProjectEncryptedS3Persistor')
|
||||
case 's3SSEC':
|
||||
return new PerProjectEncryptedS3Persistor(settings.s3SSEC)
|
||||
}
|
||||
case 'fs': {
|
||||
const FSPersistor = require('./FSPersistor')
|
||||
case 'fs':
|
||||
return new FSPersistor({
|
||||
useSubdirectories: settings.useSubdirectories,
|
||||
paths: settings.paths,
|
||||
})
|
||||
}
|
||||
case 'gcs': {
|
||||
const GcsPersistor = require('./GcsPersistor')
|
||||
case 'gcs':
|
||||
return new GcsPersistor(settings.gcs)
|
||||
}
|
||||
default:
|
||||
throw new SettingsError('unknown backend', { backend })
|
||||
}
|
||||
@@ -47,7 +44,6 @@ module.exports = function create(settings) {
|
||||
if (settings.fallback && settings.fallback.backend) {
|
||||
const primary = persistor
|
||||
const fallback = getPersistor(settings.fallback.backend, settings)
|
||||
const MigrationPersistor = require('./MigrationPersistor')
|
||||
persistor = new MigrationPersistor(primary, fallback, settings.fallback)
|
||||
}
|
||||
|
||||
|
||||
@@ -77,53 +77,14 @@ class SSECOptions {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @typedef {import('@aws-sdk/client-s3').StorageClass} StorageClass
|
||||
* @typedef {import('@aws-sdk/client-s3').PutObjectCommandInput} PutObjectCommandInput
|
||||
* @typedef {import('@aws-sdk/client-s3').HeadObjectCommandOutput} HeadObjectCommandOutput
|
||||
* @typedef {import('@aws-sdk/client-s3').GetObjectCommandOutput} GetObjectCommandOutput
|
||||
* @typedef {import('@aws-sdk/client-s3').S3ClientConfig} S3ClientConfig
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} StreamOptions
|
||||
* @property {string} [contentType]
|
||||
* @property {string} [contentEncoding]
|
||||
* @property {number} [contentLength]
|
||||
* @property {'*'} [ifNoneMatch]
|
||||
* @property {SSECOptions} [ssecOptions]
|
||||
*/
|
||||
|
||||
/**
|
||||
* @typedef {Object} S3PersistorSettings
|
||||
* @property {Object<string, StorageClass>} [storageClass]
|
||||
* @property {string} [key]
|
||||
* @property {string} [secret]
|
||||
* @property {string} [region]
|
||||
* @property {string} [endpoint]
|
||||
* @property {boolean} [pathStyle]
|
||||
* @property {number} [maxRetries]
|
||||
* @property {Object} [httpOptions]
|
||||
* @property {string} [ca]
|
||||
* @property {number} [signedUrlExpiryInMs]
|
||||
* @property {number} [partSize]
|
||||
* @property {Object<string, {auth_key: string, auth_secret: string}>} [bucketCreds]
|
||||
*/
|
||||
|
||||
class S3Persistor extends AbstractPersistor {
|
||||
/** @type {Map<string, S3Client>} */
|
||||
#clients = new Map()
|
||||
/** @type {S3PersistorSettings} */
|
||||
settings
|
||||
|
||||
/**
|
||||
* @param {S3PersistorSettings} [settings]
|
||||
*/
|
||||
constructor(settings = {}) {
|
||||
super()
|
||||
settings.storageClass = /** @type {Object<string, StorageClass>} */ (
|
||||
settings.storageClass || {}
|
||||
)
|
||||
|
||||
settings.storageClass = settings.storageClass || {}
|
||||
this.settings = settings
|
||||
}
|
||||
|
||||
@@ -141,7 +102,12 @@ class S3Persistor extends AbstractPersistor {
|
||||
* @param {string} bucketName
|
||||
* @param {string} key
|
||||
* @param {NodeJS.ReadableStream} readStream
|
||||
* @param {StreamOptions} opts
|
||||
* @param {Object} opts
|
||||
* @param {string} [opts.contentType]
|
||||
* @param {string} [opts.contentEncoding]
|
||||
* @param {number} [opts.contentLength]
|
||||
* @param {'*'} [opts.ifNoneMatch]
|
||||
* @param {SSECOptions} [opts.ssecOptions]
|
||||
* @return {Promise<void>}
|
||||
*/
|
||||
async sendStream(bucketName, key, readStream, opts = {}) {
|
||||
@@ -162,11 +128,8 @@ class S3Persistor extends AbstractPersistor {
|
||||
Body: observer,
|
||||
}
|
||||
|
||||
const storageClass = /** @type {Object<string, StorageClass>} */ (
|
||||
this.settings.storageClass
|
||||
)
|
||||
if (storageClass[bucketName]) {
|
||||
uploadOptions.StorageClass = storageClass[bucketName]
|
||||
if (this.settings.storageClass[bucketName]) {
|
||||
uploadOptions.StorageClass = this.settings.storageClass[bucketName]
|
||||
}
|
||||
|
||||
if ('sourceMd5' in opts) {
|
||||
@@ -276,9 +239,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
* @return {Promise<string>}
|
||||
*/
|
||||
async getRedirectUrl(bucketName, key) {
|
||||
const expiresSeconds = Math.round(
|
||||
/** @type {number} */ (this.settings.signedUrlExpiryInMs) / 1000
|
||||
)
|
||||
const expiresSeconds = Math.round(this.settings.signedUrlExpiryInMs / 1000)
|
||||
try {
|
||||
return await getSignedUrl(
|
||||
this._getClientForBucket(bucketName),
|
||||
@@ -350,7 +311,6 @@ class S3Persistor extends AbstractPersistor {
|
||||
*/
|
||||
async #listDirectory(bucketName, key, continuationToken) {
|
||||
let response
|
||||
/** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
|
||||
const options = { Bucket: bucketName, Prefix: key }
|
||||
if (continuationToken) {
|
||||
options.ContinuationToken = continuationToken
|
||||
@@ -541,7 +501,6 @@ class S3Persistor extends AbstractPersistor {
|
||||
*/
|
||||
async directorySize(bucketName, key, continuationToken) {
|
||||
try {
|
||||
/** @type {{ Bucket: string, Prefix: string, ContinuationToken?: string }} */
|
||||
const options = {
|
||||
Bucket: bucketName,
|
||||
Prefix: key,
|
||||
@@ -662,7 +621,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {{auth_key: string, auth_secret: string} | undefined} bucketCredentials
|
||||
* @param {Object} bucketCredentials
|
||||
* @param {import('@aws-sdk/client-s3').S3ClientConfig} clientOptions
|
||||
* @return {import('@aws-sdk/client-s3').S3ClientConfig}
|
||||
* @private
|
||||
@@ -678,7 +637,7 @@ class S3Persistor extends AbstractPersistor {
|
||||
} else if (this.settings.key) {
|
||||
options.credentials = {
|
||||
accessKeyId: this.settings.key,
|
||||
secretAccessKey: /** @type {string} */ (this.settings.secret),
|
||||
secretAccessKey: this.settings.secret,
|
||||
}
|
||||
} else {
|
||||
// Use the default credentials provider (process.env -> SSP -> ini -> IAM)
|
||||
@@ -750,9 +709,6 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
|
||||
// test-only
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
*/
|
||||
_createBucket(bucketName) {
|
||||
return this._getClientForBucket(bucketName).send(
|
||||
new CreateBucketCommand({ Bucket: bucketName })
|
||||
@@ -760,10 +716,6 @@ class S3Persistor extends AbstractPersistor {
|
||||
}
|
||||
|
||||
// test-only
|
||||
/**
|
||||
* @param {string} bucketName
|
||||
* @param {import('@aws-sdk/client-s3').PutObjectCommandInput} uploadOptions
|
||||
*/
|
||||
_upload(bucketName, uploadOptions) {
|
||||
return this._getClientForBucket(bucketName).send(
|
||||
new PutObjectCommand(uploadOptions)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const crypto = require('node:crypto')
|
||||
const os = require('node:os')
|
||||
const { expect } = require('chai')
|
||||
const mockFs = require('mock-fs')
|
||||
const fs = require('node:fs')
|
||||
const fsPromises = require('node:fs/promises')
|
||||
const Path = require('node:path')
|
||||
@@ -10,59 +10,22 @@ const Errors = require('../../src/Errors')
|
||||
|
||||
const MODULE_PATH = '../../src/FSPersistor.js'
|
||||
|
||||
function createTree(base, tree) {
|
||||
fs.mkdirSync(base, { recursive: true })
|
||||
for (const [name, content] of Object.entries(tree)) {
|
||||
const fullPath = Path.join(base, name)
|
||||
if (Buffer.isBuffer(content) || typeof content === 'string') {
|
||||
fs.writeFileSync(fullPath, content)
|
||||
} else if (content && typeof content.symlink === 'string') {
|
||||
fs.symlinkSync(content.symlink, fullPath)
|
||||
} else {
|
||||
createTree(fullPath, content)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
describe('FSPersistorTests', function () {
|
||||
const fileContents = {
|
||||
'info.txt': Buffer.from('This information is critical', {
|
||||
const localFiles = {
|
||||
'/uploads/info.txt': Buffer.from('This information is critical', {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
'other.txt': Buffer.from('Some other content', {
|
||||
'/uploads/other.txt': Buffer.from('Some other content', {
|
||||
encoding: 'utf-8',
|
||||
}),
|
||||
}
|
||||
let tmpDir
|
||||
let location
|
||||
let notADirPath
|
||||
const location = '/bucket'
|
||||
const files = {
|
||||
wombat: 'animals/wombat.tex',
|
||||
giraffe: 'animals/giraffe.tex',
|
||||
potato: 'vegetables/potato.tex',
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
tmpDir = fs.mkdtempSync(Path.join(os.tmpdir(), 'fs-persistor-test-'))
|
||||
createTree(tmpDir, {
|
||||
uploads: {
|
||||
'info.txt': fileContents['info.txt'],
|
||||
'other.txt': fileContents['other.txt'],
|
||||
},
|
||||
'not-a-dir':
|
||||
'This regular file is meant to prevent using this path as a directory',
|
||||
directory: {
|
||||
subdirectory: {},
|
||||
},
|
||||
})
|
||||
notADirPath = Path.join(tmpDir, 'not-a-dir')
|
||||
location = Path.join(tmpDir, 'bucket')
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
fs.rmSync(tmpDir, { recursive: true })
|
||||
})
|
||||
|
||||
const scenarios = [
|
||||
{
|
||||
description: 'default settings',
|
||||
@@ -91,26 +54,31 @@ describe('FSPersistorTests', function () {
|
||||
persistor = new FSPersistor(scenario.settings)
|
||||
})
|
||||
|
||||
beforeEach(function () {
|
||||
mockFs({
|
||||
...localFiles,
|
||||
'/not-a-dir':
|
||||
'This regular file is meant to prevent using this path as a directory',
|
||||
'/directory/subdirectory': {},
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(function () {
|
||||
mockFs.restore()
|
||||
})
|
||||
|
||||
describe('sendFile', function () {
|
||||
it('should copy the file', async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
||||
})
|
||||
|
||||
it('should return an error if the file cannot be stored', async function () {
|
||||
await expect(
|
||||
persistor.sendFile(
|
||||
notADirPath,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
persistor.sendFile('/not-a-dir', files.wombat, '/uploads/info.txt')
|
||||
).to.be.rejectedWith(Errors.WriteError)
|
||||
})
|
||||
})
|
||||
@@ -120,9 +88,7 @@ describe('FSPersistorTests', function () {
|
||||
|
||||
describe("when the file doesn't exist", function () {
|
||||
beforeEach(function () {
|
||||
stream = fs.createReadStream(
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
stream = fs.createReadStream('/uploads/info.txt')
|
||||
})
|
||||
|
||||
it('should write the stream to disk', async function () {
|
||||
@@ -130,7 +96,7 @@ describe('FSPersistorTests', function () {
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
||||
})
|
||||
|
||||
it('should delete the temporary file', async function () {
|
||||
@@ -143,7 +109,7 @@ describe('FSPersistorTests', function () {
|
||||
describe('on error', function () {
|
||||
beforeEach(async function () {
|
||||
await expect(
|
||||
persistor.sendStream(notADirPath, files.wombat, stream)
|
||||
persistor.sendStream('/not-a-dir', files.wombat, stream)
|
||||
).to.be.rejectedWith(Errors.WriteError)
|
||||
})
|
||||
|
||||
@@ -163,12 +129,13 @@ describe('FSPersistorTests', function () {
|
||||
describe('when the md5 hash matches', function () {
|
||||
it('should write the stream to disk', async function () {
|
||||
await persistor.sendStream(location, files.wombat, stream, {
|
||||
sourceMd5: md5(fileContents['info.txt']),
|
||||
sourceMd5: md5(localFiles['/uploads/info.txt']),
|
||||
})
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
||||
.true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -202,11 +169,9 @@ describe('FSPersistorTests', function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
stream = fs.createReadStream(
|
||||
Path.join(tmpDir, 'uploads', 'other.txt')
|
||||
'/uploads/info.txt'
|
||||
)
|
||||
stream = fs.createReadStream('/uploads/other.txt')
|
||||
})
|
||||
|
||||
it('should write the stream to disk', async function () {
|
||||
@@ -214,7 +179,7 @@ describe('FSPersistorTests', function () {
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['other.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be.true
|
||||
})
|
||||
|
||||
it('should delete the temporary file', async function () {
|
||||
@@ -227,7 +192,7 @@ describe('FSPersistorTests', function () {
|
||||
describe('on error', function () {
|
||||
beforeEach(async function () {
|
||||
await expect(
|
||||
persistor.sendStream(notADirPath, files.wombat, stream)
|
||||
persistor.sendStream('/not-a-dir', files.wombat, stream)
|
||||
).to.be.rejectedWith(Errors.WriteError)
|
||||
})
|
||||
|
||||
@@ -235,7 +200,8 @@ describe('FSPersistorTests', function () {
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('should delete the temporary file', async function () {
|
||||
@@ -249,12 +215,13 @@ describe('FSPersistorTests', function () {
|
||||
describe('when the md5 hash matches', function () {
|
||||
it('should write the stream to disk', async function () {
|
||||
await persistor.sendStream(location, files.wombat, stream, {
|
||||
sourceMd5: md5(fileContents['other.txt']),
|
||||
sourceMd5: md5(localFiles['/uploads/other.txt']),
|
||||
})
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['other.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/other.txt'])).to.be
|
||||
.true
|
||||
})
|
||||
})
|
||||
|
||||
@@ -271,7 +238,8 @@ describe('FSPersistorTests', function () {
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.wombat)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be
|
||||
.true
|
||||
})
|
||||
|
||||
it('should delete the temporary file', async function () {
|
||||
@@ -286,17 +254,13 @@ describe('FSPersistorTests', function () {
|
||||
|
||||
describe('getObjectStream', function () {
|
||||
beforeEach(async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
})
|
||||
|
||||
it('should return a string with the object contents', async function () {
|
||||
const stream = await persistor.getObjectStream(location, files.wombat)
|
||||
const contents = await streamToBuffer(stream)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
||||
})
|
||||
|
||||
it('should support ranges', async function () {
|
||||
@@ -310,8 +274,8 @@ describe('FSPersistorTests', function () {
|
||||
)
|
||||
const contents = await streamToBuffer(stream)
|
||||
// end is inclusive in ranges, but exclusive in slice()
|
||||
expect(contents.equals(fileContents['info.txt'].slice(5, 17))).to.be
|
||||
.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'].slice(5, 17)))
|
||||
.to.be.true
|
||||
})
|
||||
|
||||
it('should give a NotFoundError if the file does not exist', async function () {
|
||||
@@ -323,17 +287,13 @@ describe('FSPersistorTests', function () {
|
||||
|
||||
describe('getObjectSize', function () {
|
||||
beforeEach(async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
})
|
||||
|
||||
it('should return the file size', async function () {
|
||||
expect(
|
||||
await persistor.getObjectSize(location, files.wombat)
|
||||
).to.equal(fileContents['info.txt'].length)
|
||||
).to.equal(localFiles['/uploads/info.txt'].length)
|
||||
})
|
||||
|
||||
it('should throw a NotFoundError if the file does not exist', async function () {
|
||||
@@ -345,11 +305,7 @@ describe('FSPersistorTests', function () {
|
||||
|
||||
describe('copyObject', function () {
|
||||
beforeEach(async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
})
|
||||
|
||||
it('Should copy the file to the new location', async function () {
|
||||
@@ -357,17 +313,13 @@ describe('FSPersistorTests', function () {
|
||||
const contents = await fsPromises.readFile(
|
||||
scenario.fsPath(files.potato)
|
||||
)
|
||||
expect(contents.equals(fileContents['info.txt'])).to.be.true
|
||||
expect(contents.equals(localFiles['/uploads/info.txt'])).to.be.true
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteObject', function () {
|
||||
beforeEach(async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
await fsPromises.access(scenario.fsPath(files.wombat))
|
||||
})
|
||||
|
||||
@@ -385,11 +337,7 @@ describe('FSPersistorTests', function () {
|
||||
describe('deleteDirectory', function () {
|
||||
beforeEach(async function () {
|
||||
for (const file of Object.values(files)) {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
file,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
||||
await fsPromises.access(scenario.fsPath(file))
|
||||
}
|
||||
})
|
||||
@@ -417,11 +365,7 @@ describe('FSPersistorTests', function () {
|
||||
|
||||
describe('checkIfObjectExists', function () {
|
||||
beforeEach(async function () {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
files.wombat,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, files.wombat, '/uploads/info.txt')
|
||||
})
|
||||
|
||||
it('should return true for existing files', async function () {
|
||||
@@ -440,17 +384,13 @@ describe('FSPersistorTests', function () {
|
||||
describe('directorySize', function () {
|
||||
beforeEach(async function () {
|
||||
for (const file of Object.values(files)) {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
file,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
||||
}
|
||||
})
|
||||
|
||||
it('should sum directory files size', async function () {
|
||||
expect(await persistor.directorySize(location, 'animals')).to.equal(
|
||||
2 * fileContents['info.txt'].length
|
||||
2 * localFiles['/uploads/info.txt'].length
|
||||
)
|
||||
})
|
||||
|
||||
@@ -464,11 +404,7 @@ describe('FSPersistorTests', function () {
|
||||
describe('listDirectoryKeys', function () {
|
||||
beforeEach(async function () {
|
||||
for (const file of Object.values(files)) {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
file,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -491,11 +427,7 @@ describe('FSPersistorTests', function () {
|
||||
describe('listDirectoryStats', function () {
|
||||
beforeEach(async function () {
|
||||
for (const file of Object.values(files)) {
|
||||
await persistor.sendFile(
|
||||
location,
|
||||
file,
|
||||
Path.join(tmpDir, 'uploads', 'info.txt')
|
||||
)
|
||||
await persistor.sendFile(location, file, '/uploads/info.txt')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -506,7 +438,7 @@ describe('FSPersistorTests', function () {
|
||||
expect(keys).to.include(scenario.fsPath(files.wombat))
|
||||
expect(keys).to.include(scenario.fsPath(files.giraffe))
|
||||
for (const stat of stats) {
|
||||
expect(stat.size).to.equal(fileContents['info.txt'].length)
|
||||
expect(stat.size).to.equal(localFiles['/uploads/info.txt'].length)
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"extends": "../../tsconfig.backend.json",
|
||||
"compilerOptions": {
|
||||
"noImplicitAny": true
|
||||
},
|
||||
"include": ["**/*.js", "**/*.cjs", "**/*.ts"]
|
||||
"include": [
|
||||
"**/*.js",
|
||||
"**/*.cjs",
|
||||
"**/*.ts"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
let reporterOptions = {}
|
||||
if (process.env.CI) {
|
||||
reporterOptions = {
|
||||
reporter: require.resolve('mocha-multi-reporters'),
|
||||
reporter: '/overleaf/node_modules/mocha-multi-reporters',
|
||||
'reporter-options': ['configFile=./test/mocha-multi-reporters.cjs'],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1 +1 @@
|
||||
24.14.1
|
||||
22.18.0
|
||||
|
||||
@@ -4,7 +4,6 @@ overleaf-editor-core
|
||||
--env-pass-through=
|
||||
--esmock-loader=False
|
||||
--is-library=True
|
||||
--node-version=24.14.1
|
||||
--package-name=overleaf-editor-core
|
||||
--pipeline-owner=44
|
||||
--node-version=22.18.0
|
||||
--pipeline-owner=🥑 Core
|
||||
--public-repo=False
|
||||
|
||||
@@ -74,14 +74,6 @@ class Change {
|
||||
|
||||
static fromRaw(raw) {
|
||||
if (!raw) return null
|
||||
return Change.mustFromRaw(raw)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RawChange} raw
|
||||
* @return {Change}
|
||||
*/
|
||||
static mustFromRaw(raw) {
|
||||
assert.array.of.object(raw.operations, 'bad raw.operations')
|
||||
assert.nonEmptyString(raw.timestamp, 'bad raw.timestamp')
|
||||
|
||||
|
||||
@@ -123,19 +123,6 @@ class File {
|
||||
return rawFileData
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
toStats() {
|
||||
const stats = this.data.toStats()
|
||||
if (!_.isEmpty(this.metadata)) {
|
||||
stats.nMetadata = 1
|
||||
// Note: Buffer does not exist in frontend. Use string length instead.
|
||||
stats.metadataSize = JSON.stringify(this.metadata).length
|
||||
}
|
||||
return stats
|
||||
}
|
||||
|
||||
/**
|
||||
* Hexadecimal SHA-1 hash of the file's content, if known.
|
||||
*
|
||||
|
||||
@@ -33,16 +33,6 @@ class BinaryFileData extends FileData {
|
||||
return new BinaryFileData(raw.hash, raw.byteLength)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
toStats() {
|
||||
return {
|
||||
hashes: 1,
|
||||
byteLength: this.byteLength,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {RawBinaryFileData}
|
||||
|
||||
@@ -54,15 +54,6 @@ class HashFileData extends FileData {
|
||||
return raw
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
toStats() {
|
||||
return {
|
||||
hashes: 1 + (this.rangesHash ? 1 : 0),
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @inheritdoc
|
||||
* @returns {string}
|
||||
|
||||
@@ -36,15 +36,6 @@ class HollowBinaryFileData extends FileData {
|
||||
return { byteLength: this.byteLength }
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {Record<string, number>}
|
||||
*/
|
||||
toStats() {
|
||||
return {
|
||||
byteLength: this.byteLength,
|
||||
}
|
||||
}
|
||||
|
||||
/** @inheritdoc */
|
||||
getByteLength() {
|
||||
return this.byteLength
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user