Merge pull request #14 from outis1one/claude/read-repo-wA3y1
Claude/read repo w a3y1
This commit is contained in:
+67
@@ -0,0 +1,67 @@
|
|||||||
|
# Privacy Policy — Silent Send
|
||||||
|
|
||||||
|
**Last updated:** March 26, 2026
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Silent Send does not collect, transmit, or store any data externally. All processing happens 100% locally in your browser. There are no servers, no analytics, no tracking, and no telemetry of any kind.
|
||||||
|
|
||||||
|
## What data Silent Send accesses
|
||||||
|
|
||||||
|
Silent Send accesses the following data **only within your browser** to perform its core function (substituting personal information before it reaches AI services):
|
||||||
|
|
||||||
|
- **Text you type** in AI chat interfaces (Claude, ChatGPT, Grok, Gemini, etc.) — scanned for configured personal information and substituted before sending
|
||||||
|
- **Files you upload** to AI services — text is extracted and scanned for personal information before upload
|
||||||
|
- **Your identity configuration** — names, emails, usernames, hostnames, phones, and their substitute values, stored in browser local storage
|
||||||
|
- **Your substitution mappings** — real-to-substitute value pairs you configure
|
||||||
|
- **Activity log** — a local record of substitutions performed (never sent anywhere)
|
||||||
|
- **Settings and preferences** — extension configuration
|
||||||
|
|
||||||
|
## Where data is stored
|
||||||
|
|
||||||
|
All data is stored in your browser's `storage.local` (the extension's private storage area). When at-rest encryption is enabled, all sensitive data is AES-256-GCM encrypted before being written to storage.
|
||||||
|
|
||||||
|
Data is **never** sent to any server operated by Silent Send or any third party. The only network requests Silent Send makes are:
|
||||||
|
|
||||||
|
- **To the AI service you are already using** (e.g., claude.ai, chatgpt.com) — this is the substituted/sanitized version of your text, not the original
|
||||||
|
- **GitHub Gist sync** (optional, user-initiated) — if you configure Gist sync, your encrypted settings are stored in a private Gist on your own GitHub account
|
||||||
|
- **Custom URL sync** (optional, user-initiated) — if you configure a custom sync endpoint, encrypted settings are sent to the URL you specify
|
||||||
|
- **Org policy URL** (optional) — if you join an organization, the extension fetches the policy JSON from the URL your admin provides
|
||||||
|
|
||||||
|
## Data sharing
|
||||||
|
|
||||||
|
Silent Send does not share any data with anyone. There are no analytics providers, no crash reporting services, no advertising networks, and no data brokers involved.
|
||||||
|
|
||||||
|
## Data retention
|
||||||
|
|
||||||
|
All data persists in your browser until you delete it. You can:
|
||||||
|
- Clear all data via Options → Danger Zone → Reset Everything
|
||||||
|
- Uninstall the extension (removes all stored data)
|
||||||
|
- Export your data before clearing
|
||||||
|
|
||||||
|
## Permissions explained
|
||||||
|
|
||||||
|
| Permission | Why it's needed |
|
||||||
|
|---|---|
|
||||||
|
| `storage` | Store your identity, mappings, settings, and activity log locally |
|
||||||
|
| `activeTab` | Access the current tab to inject the substitution script |
|
||||||
|
| `scripting` | Inject content scripts on custom domains you configure |
|
||||||
|
| `notifications` | Show desktop notifications for sync status updates |
|
||||||
|
| `alarms` | Background polling for auto-sync and org policy updates |
|
||||||
|
| Host permissions (claude.ai, etc.) | Intercept API requests to substitute personal information before sending |
|
||||||
|
|
||||||
|
## Children's privacy
|
||||||
|
|
||||||
|
Silent Send does not knowingly collect data from children under 13. The extension does not collect data from anyone — it processes everything locally.
|
||||||
|
|
||||||
|
## Changes to this policy
|
||||||
|
|
||||||
|
If this privacy policy changes, the updated version will be posted at this URL and in the extension's GitHub repository.
|
||||||
|
|
||||||
|
## Contact
|
||||||
|
|
||||||
|
For questions about this privacy policy, open an issue at: https://github.com/outis1one/silent-send/issues
|
||||||
|
|
||||||
|
## Open source
|
||||||
|
|
||||||
|
Silent Send's source code is publicly available at https://github.com/outis1one/silent-send — you can verify every claim in this policy by reading the code.
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 1,
|
"manifest_version": 3,
|
||||||
"name": "Silent Send",
|
"name": "Silent Send",
|
||||||
"version": "1.2.0",
|
"version": "2.0.0",
|
||||||
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
||||||
"browser_specific_settings": {
|
"browser_specific_settings": {
|
||||||
"gecko": {
|
"gecko": {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"manifest_version": 3,
|
"manifest_version": 3,
|
||||||
"name": "Silent Send",
|
"name": "Silent Send",
|
||||||
"version": "0.3.1",
|
"version": "2.0.0",
|
||||||
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
"description": "Intercepts personal info and substitutes it with user-defined replacements before sending to AI services.",
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"storage",
|
"storage",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "silent-send",
|
"name": "silent-send",
|
||||||
"version": "0.3.1",
|
"version": "2.0.0",
|
||||||
"private": true,
|
"private": true,
|
||||||
"license": "BSL-1.1",
|
"license": "BSL-1.1",
|
||||||
"description": "Browser extension that substitutes personal data before sending to AI services",
|
"description": "Browser extension that substitutes personal data before sending to AI services",
|
||||||
|
|||||||
+57
-33
@@ -1,7 +1,9 @@
|
|||||||
#!/bin/bash
|
#!/bin/bash
|
||||||
#
|
#
|
||||||
# Sign the Firefox extension using Mozilla's API.
|
# Sign the Firefox extension using Mozilla's API.
|
||||||
# Auto-bumps the patch version to avoid "version already exists" conflicts.
|
# Tries the current version first. Only bumps if that version
|
||||||
|
# already exists at Mozilla. Handles rate limiting with backoff.
|
||||||
|
#
|
||||||
# Reads credentials from .env file.
|
# Reads credentials from .env file.
|
||||||
#
|
#
|
||||||
|
|
||||||
@@ -20,25 +22,12 @@ if [ ! -f "$ENV_FILE" ]; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# --- Auto-bump version — always unique, no metadata ---
|
# --- Read current version (don't bump yet — try current first) ---
|
||||||
# Reads current version, increments patch. If already signed,
|
|
||||||
# keeps incrementing until it works.
|
|
||||||
CURRENT_VERSION=$(grep -o '"version": "[^"]*"' "$MANIFEST" | head -1 | grep -o '[0-9.]*')
|
CURRENT_VERSION=$(grep -o '"version": "[^"]*"' "$MANIFEST" | head -1 | grep -o '[0-9.]*')
|
||||||
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
|
IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION"
|
||||||
PATCH=$((PATCH + 1))
|
NEW_VERSION="$CURRENT_VERSION"
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
|
||||||
|
|
||||||
echo "Version: $CURRENT_VERSION → $NEW_VERSION"
|
echo "Current version: $CURRENT_VERSION"
|
||||||
|
|
||||||
# Update all version references (only top-level "version", not "manifest_version")
|
|
||||||
sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST"
|
|
||||||
sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST_CHROME"
|
|
||||||
sed -i "s/^ \"version\": \"$CURRENT_VERSION\"/ \"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON"
|
|
||||||
|
|
||||||
# Commit the version bump
|
|
||||||
cd "$SCRIPT_DIR"
|
|
||||||
git add manifest.json manifest.firefox.json package.json 2>/dev/null
|
|
||||||
git commit -m "chore: auto-bump version to $NEW_VERSION for Firefox signing" --allow-empty 2>/dev/null || true
|
|
||||||
|
|
||||||
# --- Parse .env ---
|
# --- Parse .env ---
|
||||||
API_KEY=""
|
API_KEY=""
|
||||||
@@ -88,38 +77,73 @@ echo ""
|
|||||||
echo "Building Firefox extension..."
|
echo "Building Firefox extension..."
|
||||||
"$SCRIPT_DIR/build.sh" firefox
|
"$SCRIPT_DIR/build.sh" firefox
|
||||||
|
|
||||||
# Sign — retry with incremented patch if version conflict
|
# --- Sign with retry ---
|
||||||
MAX_ATTEMPTS=10
|
MAX_ATTEMPTS=5
|
||||||
for attempt in $(seq 1 $MAX_ATTEMPTS); do
|
WAIT_TIME=10
|
||||||
echo "Signing v$NEW_VERSION with Mozilla (attempt $attempt)..."
|
|
||||||
|
|
||||||
if npx web-ext sign \
|
for attempt in $(seq 1 $MAX_ATTEMPTS); do
|
||||||
|
echo ""
|
||||||
|
echo "=== Attempt $attempt: signing v$NEW_VERSION ==="
|
||||||
|
|
||||||
|
# Capture output to check for specific errors
|
||||||
|
OUTPUT=$(npx web-ext sign \
|
||||||
--no-config-discovery \
|
--no-config-discovery \
|
||||||
--source-dir "$SCRIPT_DIR/dist/firefox" \
|
--source-dir "$SCRIPT_DIR/dist/firefox" \
|
||||||
--artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \
|
--artifacts-dir "$SCRIPT_DIR/dist/firefox-signed" \
|
||||||
--channel unlisted \
|
--channel unlisted \
|
||||||
--api-key "$API_KEY" \
|
--api-key "$API_KEY" \
|
||||||
--api-secret "$API_SECRET" 2>&1; then
|
--api-secret "$API_SECRET" 2>&1) && {
|
||||||
|
echo "$OUTPUT"
|
||||||
echo ""
|
echo ""
|
||||||
echo "Done! v$NEW_VERSION signed."
|
echo "Success! v$NEW_VERSION signed."
|
||||||
echo "Install the .xpi file from dist/firefox-signed/"
|
echo "Install: dist/firefox-signed/"
|
||||||
echo "Drag it into Firefox or use File → Open File."
|
|
||||||
|
# Update source files to match the signed version
|
||||||
|
sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST"
|
||||||
|
sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$MANIFEST_CHROME"
|
||||||
|
sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$PACKAGE_JSON"
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR"
|
||||||
|
git add manifest.json manifest.firefox.json package.json 2>/dev/null
|
||||||
|
git commit -m "chore: release v$NEW_VERSION (Firefox signed)" --allow-empty 2>/dev/null || true
|
||||||
|
|
||||||
exit 0
|
exit 0
|
||||||
|
}
|
||||||
|
|
||||||
|
echo "$OUTPUT"
|
||||||
|
|
||||||
|
# Check if rate limited
|
||||||
|
if echo "$OUTPUT" | grep -q "throttled"; then
|
||||||
|
# Extract wait time from error message
|
||||||
|
THROTTLE_SECS=$(echo "$OUTPUT" | grep -oP 'available in \K\d+' || echo "60")
|
||||||
|
echo ""
|
||||||
|
echo "Rate limited by Mozilla. Waiting ${THROTTLE_SECS}s..."
|
||||||
|
sleep "$THROTTLE_SECS"
|
||||||
|
# Don't bump version — retry the same version after cooldown
|
||||||
|
continue
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# If it failed due to version conflict, bump and rebuild
|
# Check if version already exists
|
||||||
echo "Version $NEW_VERSION already exists, trying next..."
|
if echo "$OUTPUT" | grep -qi "already exists\|version.*conflict\|could not be uploaded"; then
|
||||||
PATCH=$((PATCH + 1))
|
PATCH=$((PATCH + 1))
|
||||||
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
NEW_VERSION="$MAJOR.$MINOR.$PATCH"
|
||||||
|
echo ""
|
||||||
|
echo "Version conflict. Bumping to $NEW_VERSION..."
|
||||||
|
|
||||||
# Only replace the top-level "version" field, not "manifest_version"
|
# Update only the built manifest (not source — we'll update source on success)
|
||||||
sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json"
|
sed -i "s/^ \"version\": \"[^\"]*\"/ \"version\": \"$NEW_VERSION\"/" "$SCRIPT_DIR/dist/firefox/manifest.json"
|
||||||
|
|
||||||
# Wait before retrying to avoid Mozilla rate limiting
|
sleep "$WAIT_TIME"
|
||||||
echo "Waiting 8 seconds before retry..."
|
continue
|
||||||
sleep 8
|
fi
|
||||||
|
|
||||||
|
# Unknown error — wait and retry
|
||||||
|
echo ""
|
||||||
|
echo "Unknown error. Waiting ${WAIT_TIME}s before retry..."
|
||||||
|
sleep "$WAIT_TIME"
|
||||||
done
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
echo "Error: Failed after $MAX_ATTEMPTS attempts."
|
echo "Error: Failed after $MAX_ATTEMPTS attempts."
|
||||||
|
echo "If rate limited, wait a few minutes and try again."
|
||||||
exit 1
|
exit 1
|
||||||
|
|||||||
+13
-6
@@ -10,6 +10,13 @@
|
|||||||
(function () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
// --- Safe innerHTML replacement (AMO-compliant, page world) ---
|
||||||
|
function safeHTML(el, html) {
|
||||||
|
const template = document.createElement('template');
|
||||||
|
template.innerHTML = html;
|
||||||
|
el.replaceChildren(...template.content.childNodes);
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================
|
// ============================================================
|
||||||
// Load config from the injector script's data attribute
|
// Load config from the injector script's data attribute
|
||||||
// ============================================================
|
// ============================================================
|
||||||
@@ -514,7 +521,7 @@
|
|||||||
|
|
||||||
const more = warnings.length > 5 ? `<div class="ss-ad-more">+${warnings.length - 5} more</div>` : '';
|
const more = warnings.length > 5 ? `<div class="ss-ad-more">+${warnings.length - 5} more</div>` : '';
|
||||||
|
|
||||||
warningEl.innerHTML = `
|
safeHTML(warningEl, `
|
||||||
<div class="ss-ad-header">
|
<div class="ss-ad-header">
|
||||||
<strong>Silent Send detected potential PPI that may not be substituted:</strong>
|
<strong>Silent Send detected potential PPI that may not be substituted:</strong>
|
||||||
<button class="ss-ad-close">×</button>
|
<button class="ss-ad-close">×</button>
|
||||||
@@ -522,7 +529,7 @@
|
|||||||
${items}
|
${items}
|
||||||
${more}
|
${more}
|
||||||
<div class="ss-ad-footer">${settings.autoRedactDetected !== false ? 'Auto-redacted before sending.' : 'These were sent as-is.'} Consider adding them to your identity or mappings.</div>
|
<div class="ss-ad-footer">${settings.autoRedactDetected !== false ? 'Auto-redacted before sending.' : 'These were sent as-is.'} Consider adding them to your identity or mappings.</div>
|
||||||
`;
|
`);
|
||||||
|
|
||||||
warningEl.classList.add('visible');
|
warningEl.classList.add('visible');
|
||||||
|
|
||||||
@@ -1165,7 +1172,7 @@
|
|||||||
</div>`
|
</div>`
|
||||||
).join('');
|
).join('');
|
||||||
|
|
||||||
docPreviewEl.innerHTML = `
|
safeHTML(docPreviewEl, `
|
||||||
<div class="ss-dp-header">
|
<div class="ss-dp-header">
|
||||||
<strong>PPI found in ${esc(filename)}</strong>
|
<strong>PPI found in ${esc(filename)}</strong>
|
||||||
<span class="ss-dp-count">${preview.replacementCount} item(s)</span>
|
<span class="ss-dp-count">${preview.replacementCount} item(s)</span>
|
||||||
@@ -1176,7 +1183,7 @@
|
|||||||
<button class="ss-dp-btn ss-dp-confirm">Substitute & Upload</button>
|
<button class="ss-dp-btn ss-dp-confirm">Substitute & Upload</button>
|
||||||
<button class="ss-dp-btn ss-dp-cancel">Upload Original</button>
|
<button class="ss-dp-btn ss-dp-cancel">Upload Original</button>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
docPreviewEl.classList.add('visible');
|
docPreviewEl.classList.add('visible');
|
||||||
const confirm = docPreviewEl.querySelector('.ss-dp-confirm');
|
const confirm = docPreviewEl.querySelector('.ss-dp-confirm');
|
||||||
const cancel = docPreviewEl.querySelector('.ss-dp-cancel');
|
const cancel = docPreviewEl.querySelector('.ss-dp-cancel');
|
||||||
@@ -1657,7 +1664,7 @@
|
|||||||
|
|
||||||
const more = warnings.length > 8 ? `<div class="ss-ad-more">+${warnings.length - 8} more</div>` : '';
|
const more = warnings.length > 8 ? `<div class="ss-ad-more">+${warnings.length - 8} more</div>` : '';
|
||||||
|
|
||||||
preSendWarningEl.innerHTML = `
|
safeHTML(preSendWarningEl, `
|
||||||
<div class="ss-ad-header">
|
<div class="ss-ad-header">
|
||||||
<strong>Potential PPI detected — not yet configured:</strong>
|
<strong>Potential PPI detected — not yet configured:</strong>
|
||||||
<button class="ss-ad-close">×</button>
|
<button class="ss-ad-close">×</button>
|
||||||
@@ -1668,7 +1675,7 @@
|
|||||||
${settings.autoRedactDetected !== false ? 'Auto-redacted with standard placeholders.' : 'These were sent as-is.'}
|
${settings.autoRedactDetected !== false ? 'Auto-redacted with standard placeholders.' : 'These were sent as-is.'}
|
||||||
${settings.autoAddDetected !== false ? ' Click + to add a permanent mapping.' : ''}
|
${settings.autoAddDetected !== false ? ' Click + to add a permanent mapping.' : ''}
|
||||||
</div>
|
</div>
|
||||||
`;
|
`);
|
||||||
|
|
||||||
preSendWarningEl.classList.add('visible');
|
preSendWarningEl.classList.add('visible');
|
||||||
|
|
||||||
|
|||||||
@@ -589,7 +589,7 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<footer>
|
<footer>
|
||||||
<p>Silent Send v0.3.0</p>
|
<p>Silent Send v2.0.0</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
+32
-26
@@ -14,6 +14,12 @@ let passwordsRevealed = false;
|
|||||||
|
|
||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
|
|
||||||
|
// --- Safe innerHTML replacement (AMO-compliant) ---
|
||||||
|
function safeHTML(el, html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
el.replaceChildren(...doc.body.childNodes);
|
||||||
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
mappings = await Storage.getMappings();
|
mappings = await Storage.getMappings();
|
||||||
settings = await Storage.getSettings();
|
settings = await Storage.getSettings();
|
||||||
@@ -405,11 +411,11 @@ function renderMappings() {
|
|||||||
const nonPasswordMappings = mappings.filter(m => m.category !== 'password');
|
const nonPasswordMappings = mappings.filter(m => m.category !== 'password');
|
||||||
|
|
||||||
if (nonPasswordMappings.length === 0) {
|
if (nonPasswordMappings.length === 0) {
|
||||||
tbody.innerHTML = '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>';
|
safeHTML(tbody, '<tr><td colspan="6" style="text-align:center;color:#9ca3af;padding:24px">No mappings configured</td></tr>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody.innerHTML = nonPasswordMappings
|
safeHTML(tbody, nonPasswordMappings
|
||||||
.map(
|
.map(
|
||||||
(m) => `
|
(m) => `
|
||||||
<tr data-id="${m.id}">
|
<tr data-id="${m.id}">
|
||||||
@@ -427,7 +433,7 @@ function renderMappings() {
|
|||||||
</tr>
|
</tr>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.join('');
|
.join(''));
|
||||||
|
|
||||||
// Bind
|
// Bind
|
||||||
tbody.querySelectorAll('.btn-delete').forEach((btn) => {
|
tbody.querySelectorAll('.btn-delete').forEach((btn) => {
|
||||||
@@ -457,14 +463,14 @@ function renderPasswords() {
|
|||||||
const noMsg = $('#noPasswordsMsg');
|
const noMsg = $('#noPasswordsMsg');
|
||||||
|
|
||||||
if (passwordMappings.length === 0) {
|
if (passwordMappings.length === 0) {
|
||||||
tbody.innerHTML = '';
|
tbody.replaceChildren();
|
||||||
noMsg.style.display = 'block';
|
noMsg.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
noMsg.style.display = 'none';
|
noMsg.style.display = 'none';
|
||||||
|
|
||||||
tbody.innerHTML = passwordMappings.map(m => {
|
safeHTML(tbody, passwordMappings.map(m => {
|
||||||
const displayReal = passwordsRevealed
|
const displayReal = passwordsRevealed
|
||||||
? escapeHtml(m.real)
|
? escapeHtml(m.real)
|
||||||
: '••••••••';
|
: '••••••••';
|
||||||
@@ -481,7 +487,7 @@ function renderPasswords() {
|
|||||||
</td>
|
</td>
|
||||||
<td><button class="btn btn-sm btn-danger btn-delete-pw">×</button></td>
|
<td><button class="btn btn-sm btn-danger btn-delete-pw">×</button></td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('');
|
}).join(''));
|
||||||
|
|
||||||
// Bind delete
|
// Bind delete
|
||||||
tbody.querySelectorAll('.btn-delete-pw').forEach(btn => {
|
tbody.querySelectorAll('.btn-delete-pw').forEach(btn => {
|
||||||
@@ -518,11 +524,11 @@ async function renderLog() {
|
|||||||
|
|
||||||
const list = $('#logList');
|
const list = $('#logList');
|
||||||
if (log.length === 0) {
|
if (log.length === 0) {
|
||||||
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:24px">No activity logged</div>';
|
safeHTML(list, '<div style="text-align:center;color:#9ca3af;padding:24px">No activity logged</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = log
|
safeHTML(list, log
|
||||||
.slice(0, 100)
|
.slice(0, 100)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const time = new Date(entry.timestamp).toLocaleString();
|
const time = new Date(entry.timestamp).toLocaleString();
|
||||||
@@ -535,7 +541,7 @@ async function renderLog() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Custom Domains ---
|
// --- Custom Domains ---
|
||||||
@@ -582,18 +588,18 @@ function renderDomains() {
|
|||||||
const domains = settings.customDomains || [];
|
const domains = settings.customDomains || [];
|
||||||
|
|
||||||
if (domains.length === 0) {
|
if (domains.length === 0) {
|
||||||
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px;font-size:13px">No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.</div>';
|
safeHTML(list, '<div style="text-align:center;color:#9ca3af;padding:12px;font-size:13px">No custom domains. Built-in sites (Claude, ChatGPT, Grok, Gemini, localhost) are always active.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = domains
|
safeHTML(list, domains
|
||||||
.map((d, i) => `
|
.map((d, i) => `
|
||||||
<div class="domain-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
|
<div class="domain-item" style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
|
||||||
<span style="font-size:13px;font-family:monospace">${escapeHtml(d)}</span>
|
<span style="font-size:13px;font-family:monospace">${escapeHtml(d)}</span>
|
||||||
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}">×</button>
|
<button class="btn btn-sm btn-danger btn-remove-domain" data-index="${i}">×</button>
|
||||||
</div>
|
</div>
|
||||||
`)
|
`)
|
||||||
.join('');
|
.join(''));
|
||||||
|
|
||||||
list.querySelectorAll('.btn-remove-domain').forEach((btn) => {
|
list.querySelectorAll('.btn-remove-domain').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -1239,11 +1245,11 @@ async function renderVersionHistory() {
|
|||||||
const snapshots = await VersionHistory.getSnapshots();
|
const snapshots = await VersionHistory.getSnapshots();
|
||||||
|
|
||||||
if (snapshots.length === 0) {
|
if (snapshots.length === 0) {
|
||||||
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No snapshots yet. Snapshots are created on each sync.</div>';
|
safeHTML(list, '<div style="text-align:center;color:#9ca3af;padding:12px">No snapshots yet. Snapshots are created on each sync.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = snapshots.map(s => {
|
safeHTML(list, snapshots.map(s => {
|
||||||
const time = new Date(s.timestamp).toLocaleString();
|
const time = new Date(s.timestamp).toLocaleString();
|
||||||
const mappingCount = (s.data?.mappings || []).length;
|
const mappingCount = (s.data?.mappings || []).length;
|
||||||
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
|
return `<div style="display:flex;align-items:center;justify-content:space-between;padding:8px;background:#f9fafb;border-radius:6px;margin-bottom:4px">
|
||||||
@@ -1254,7 +1260,7 @@ async function renderVersionHistory() {
|
|||||||
</div>
|
</div>
|
||||||
<button class="btn btn-sm btn-restore-snapshot" data-id="${s.id}">Restore</button>
|
<button class="btn btn-sm btn-restore-snapshot" data-id="${s.id}">Restore</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join(''));
|
||||||
|
|
||||||
list.querySelectorAll('.btn-restore-snapshot').forEach(btn => {
|
list.querySelectorAll('.btn-restore-snapshot').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -1299,13 +1305,13 @@ async function renderDevices() {
|
|||||||
const entries = Object.values(devices);
|
const entries = Object.values(devices);
|
||||||
|
|
||||||
if (entries.length === 0) {
|
if (entries.length === 0) {
|
||||||
list.innerHTML = '<div style="text-align:center;color:#9ca3af;padding:12px">No devices synced yet. Push or pull to register this device.</div>';
|
safeHTML(list, '<div style="text-align:center;color:#9ca3af;padding:12px">No devices synced yet. Push or pull to register this device.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0));
|
entries.sort((a, b) => (b.lastSync || 0) - (a.lastSync || 0));
|
||||||
|
|
||||||
list.innerHTML = `<table style="width:100%;font-size:12px;border-collapse:collapse">
|
safeHTML(list, `<table style="width:100%;font-size:12px;border-collapse:collapse">
|
||||||
<thead><tr style="text-align:left;border-bottom:1px solid #e5e7eb">
|
<thead><tr style="text-align:left;border-bottom:1px solid #e5e7eb">
|
||||||
<th style="padding:6px">Device</th>
|
<th style="padding:6px">Device</th>
|
||||||
<th style="padding:6px">Browser</th>
|
<th style="padding:6px">Browser</th>
|
||||||
@@ -1322,7 +1328,7 @@ async function renderDevices() {
|
|||||||
<td style="padding:6px">${!isCurrent ? `<button class="btn btn-sm btn-danger btn-remove-device" data-id="${d.id}">×</button>` : ''}</td>
|
<td style="padding:6px">${!isCurrent ? `<button class="btn btn-sm btn-danger btn-remove-device" data-id="${d.id}">×</button>` : ''}</td>
|
||||||
</tr>`;
|
</tr>`;
|
||||||
}).join('')}</tbody>
|
}).join('')}</tbody>
|
||||||
</table>`;
|
</table>`);
|
||||||
|
|
||||||
list.querySelectorAll('.btn-remove-device').forEach(btn => {
|
list.querySelectorAll('.btn-remove-device').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -1399,9 +1405,9 @@ async function showOrgJoined() {
|
|||||||
const compliance = await OrgPolicy.checkCompliance();
|
const compliance = await OrgPolicy.checkCompliance();
|
||||||
const statusEl = $('#orgComplianceStatus');
|
const statusEl = $('#orgComplianceStatus');
|
||||||
if (compliance.compliant) {
|
if (compliance.compliant) {
|
||||||
statusEl.innerHTML = '<span style="color:#10b981">✓ Compliant — all required fields configured</span>';
|
safeHTML(statusEl, '<span style="color:#10b981">✓ Compliant — all required fields configured</span>');
|
||||||
} else {
|
} else {
|
||||||
statusEl.innerHTML = `<span style="color:#b45309">Missing: ${compliance.missing.join(', ')}</span>`;
|
safeHTML(statusEl, `<span style="color:#b45309">Missing: ${compliance.missing.join(', ')}</span>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const reqMappings = policy?.requiredMappings || [];
|
const reqMappings = policy?.requiredMappings || [];
|
||||||
@@ -1553,7 +1559,7 @@ async function checkConflicts() {
|
|||||||
|
|
||||||
function renderConflicts(conflicts) {
|
function renderConflicts(conflicts) {
|
||||||
const list = $('#conflictList');
|
const list = $('#conflictList');
|
||||||
list.innerHTML = conflicts.map(c => `
|
safeHTML(list, conflicts.map(c => `
|
||||||
<div style="padding:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;margin-bottom:8px" data-conflict-id="${c.id}">
|
<div style="padding:10px;background:#fffbeb;border:1px solid #fde68a;border-radius:6px;margin-bottom:8px" data-conflict-id="${c.id}">
|
||||||
<div style="font-size:12px;font-weight:500;margin-bottom:6px">${escapeHtml(c.path)}</div>
|
<div style="font-size:12px;font-weight:500;margin-bottom:6px">${escapeHtml(c.path)}</div>
|
||||||
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
|
<div style="display:flex;gap:12px;flex-wrap:wrap;margin-bottom:8px">
|
||||||
@@ -1571,7 +1577,7 @@ function renderConflicts(conflicts) {
|
|||||||
<button class="btn btn-sm btn-resolve" data-id="${c.id}" data-choice="remote">Keep Remote</button>
|
<button class="btn btn-sm btn-resolve" data-id="${c.id}" data-choice="remote">Keep Remote</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join(''));
|
||||||
|
|
||||||
list.querySelectorAll('.btn-resolve').forEach(btn => {
|
list.querySelectorAll('.btn-resolve').forEach(btn => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
@@ -1632,10 +1638,10 @@ async function handleBulkImport(e) {
|
|||||||
result.identity.usernames.filter(u => !u.substitute).length +
|
result.identity.usernames.filter(u => !u.substitute).length +
|
||||||
result.identity.phones.filter(p => !p.substitute).length;
|
result.identity.phones.filter(p => !p.substitute).length;
|
||||||
|
|
||||||
$('#bulkImportSummary').innerHTML = `
|
safeHTML($('#bulkImportSummary'), `
|
||||||
Found: ${parts.join(', ')}.
|
Found: ${parts.join(', ')}.
|
||||||
${needsMapping > 0 ? `<span style="color:#b45309">${needsMapping} item(s) need substitutes — you can add them after import.</span>` : ''}
|
${needsMapping > 0 ? `<span style="color:#b45309">${needsMapping} item(s) need substitutes — you can add them after import.</span>` : ''}
|
||||||
`;
|
`);
|
||||||
|
|
||||||
// Build preview list
|
// Build preview list
|
||||||
const items = [];
|
const items = [];
|
||||||
@@ -1655,8 +1661,8 @@ async function handleBulkImport(e) {
|
|||||||
items.push(`<div><span style="color:#6b7280">${escapeHtml(m.category)}:</span> <strong>${escapeHtml(m.real)}</strong>${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
items.push(`<div><span style="color:#6b7280">${escapeHtml(m.category)}:</span> <strong>${escapeHtml(m.real)}</strong>${m.substitute ? ' → ' + escapeHtml(m.substitute) : ' <span style="color:#b45309">needs substitute</span>'}</div>`);
|
||||||
}
|
}
|
||||||
|
|
||||||
$('#bulkImportItems').innerHTML = items.slice(0, 50).join('') +
|
safeHTML($('#bulkImportItems'), items.slice(0, 50).join('') +
|
||||||
(items.length > 50 ? `<div style="color:#6b7280;margin-top:4px">+${items.length - 50} more...</div>` : '');
|
(items.length > 50 ? `<div style="color:#6b7280;margin-top:4px">+${items.length - 50} more...</div>` : ''));
|
||||||
|
|
||||||
$('#bulkImportPreview').style.display = 'block';
|
$('#bulkImportPreview').style.display = 'block';
|
||||||
|
|
||||||
|
|||||||
+26
-18
@@ -18,6 +18,12 @@ let settings = {};
|
|||||||
const $ = (sel) => document.querySelector(sel);
|
const $ = (sel) => document.querySelector(sel);
|
||||||
const $$ = (sel) => document.querySelectorAll(sel);
|
const $$ = (sel) => document.querySelectorAll(sel);
|
||||||
|
|
||||||
|
// --- Safe innerHTML replacement (AMO-compliant) ---
|
||||||
|
function safeHTML(el, html) {
|
||||||
|
const doc = new DOMParser().parseFromString(html, 'text/html');
|
||||||
|
el.replaceChildren(...doc.body.childNodes);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Init ---
|
// --- Init ---
|
||||||
document.addEventListener('DOMContentLoaded', async () => {
|
document.addEventListener('DOMContentLoaded', async () => {
|
||||||
// Check if locked BEFORE trying to read sensitive data
|
// Check if locked BEFORE trying to read sensitive data
|
||||||
@@ -302,11 +308,11 @@ async function showLockedUI() {
|
|||||||
// --- Profiles ---
|
// --- Profiles ---
|
||||||
function renderProfileSelector() {
|
function renderProfileSelector() {
|
||||||
const select = $('#profileSelect');
|
const select = $('#profileSelect');
|
||||||
select.innerHTML = profiles.map(p =>
|
safeHTML(select, profiles.map(p =>
|
||||||
`<option value="${p.id}" ${p.id === currentProfileId ? 'selected' : ''}>` +
|
`<option value="${p.id}" ${p.id === currentProfileId ? 'selected' : ''}>` +
|
||||||
`${escapeHtml(p.name)}${p.active ? '' : ' (off)'}` +
|
`${escapeHtml(p.name)}${p.active ? '' : ' (off)'}` +
|
||||||
`</option>`
|
`</option>`
|
||||||
).join('');
|
).join(''));
|
||||||
|
|
||||||
const profile = profiles.find(p => p.id === currentProfileId);
|
const profile = profiles.find(p => p.id === currentProfileId);
|
||||||
$('#profileActive').checked = profile?.active ?? true;
|
$('#profileActive').checked = profile?.active ?? true;
|
||||||
@@ -355,7 +361,7 @@ function renderFieldList(fieldName, items) {
|
|||||||
items = [{ real: '', substitute: '', type: config.defaultType || '' }];
|
items = [{ real: '', substitute: '', type: config.defaultType || '' }];
|
||||||
}
|
}
|
||||||
|
|
||||||
container.innerHTML = items.map((item, i) => {
|
safeHTML(container, items.map((item, i) => {
|
||||||
let typeHtml = '';
|
let typeHtml = '';
|
||||||
if (config.typeOptions) {
|
if (config.typeOptions) {
|
||||||
typeHtml = `<select class="id-type-select" data-index="${i}" style="padding:3px 2px;font-size:10px;border:1px solid #e5e7eb;border-radius:3px;width:42px">` +
|
typeHtml = `<select class="id-type-select" data-index="${i}" style="padding:3px 2px;font-size:10px;border:1px solid #e5e7eb;border-radius:3px;width:42px">` +
|
||||||
@@ -371,7 +377,7 @@ function renderFieldList(fieldName, items) {
|
|||||||
<input type="text" class="input input-sm id-sub" value="${escapeAttr(item.substitute || '')}" placeholder="${config.placeholderSub}">
|
<input type="text" class="input input-sm id-sub" value="${escapeAttr(item.substitute || '')}" placeholder="${config.placeholderSub}">
|
||||||
<button class="btn-remove" title="Remove">×</button>
|
<button class="btn-remove" title="Remove">×</button>
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
}).join(''));
|
||||||
|
|
||||||
// Bind remove buttons
|
// Bind remove buttons
|
||||||
container.querySelectorAll('.btn-remove').forEach(btn => {
|
container.querySelectorAll('.btn-remove').forEach(btn => {
|
||||||
@@ -420,13 +426,13 @@ function loadIdentityForm() {
|
|||||||
).join('') +
|
).join('') +
|
||||||
`</select>`;
|
`</select>`;
|
||||||
}
|
}
|
||||||
tempDiv.innerHTML = `<div class="id-entry-row" data-index="${count}">
|
safeHTML(tempDiv, `<div class="id-entry-row" data-index="${count}">
|
||||||
${typeHtml}
|
${typeHtml}
|
||||||
<input type="text" class="input input-sm id-real" placeholder="${config.placeholderReal}">
|
<input type="text" class="input input-sm id-real" placeholder="${config.placeholderReal}">
|
||||||
<span class="arrow" style="font-size:12px">→</span>
|
<span class="arrow" style="font-size:12px">→</span>
|
||||||
<input type="text" class="input input-sm id-sub" placeholder="${config.placeholderSub}">
|
<input type="text" class="input input-sm id-sub" placeholder="${config.placeholderSub}">
|
||||||
<button class="btn-remove" title="Remove">×</button>
|
<button class="btn-remove" title="Remove">×</button>
|
||||||
</div>`;
|
</div>`);
|
||||||
const row = tempDiv.firstElementChild;
|
const row = tempDiv.firstElementChild;
|
||||||
container.appendChild(row);
|
container.appendChild(row);
|
||||||
row.querySelector('.btn-remove').addEventListener('click', () => {
|
row.querySelector('.btn-remove').addEventListener('click', () => {
|
||||||
@@ -576,11 +582,11 @@ function renderMappings() {
|
|||||||
const list = $('#mappingList');
|
const list = $('#mappingList');
|
||||||
|
|
||||||
if (mappings.length === 0) {
|
if (mappings.length === 0) {
|
||||||
list.innerHTML = '<div class="empty-state">No mappings yet. Add your first one above.</div>';
|
safeHTML(list, '<div class="empty-state">No mappings yet. Add your first one above.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = mappings
|
safeHTML(list, mappings
|
||||||
.map(
|
.map(
|
||||||
(m) => `
|
(m) => `
|
||||||
<div class="mapping-item" data-id="${m.id}">
|
<div class="mapping-item" data-id="${m.id}">
|
||||||
@@ -597,7 +603,7 @@ function renderMappings() {
|
|||||||
</div>
|
</div>
|
||||||
`
|
`
|
||||||
)
|
)
|
||||||
.join('');
|
.join(''));
|
||||||
|
|
||||||
// Bind actions
|
// Bind actions
|
||||||
list.querySelectorAll('.btn-delete').forEach((btn) => {
|
list.querySelectorAll('.btn-delete').forEach((btn) => {
|
||||||
@@ -631,11 +637,11 @@ async function renderActivity() {
|
|||||||
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
|
countEl.textContent = `${log.length} substitution${log.length !== 1 ? 's' : ''} logged`;
|
||||||
|
|
||||||
if (log.length === 0) {
|
if (log.length === 0) {
|
||||||
list.innerHTML = '<div class="empty-state">No activity yet.</div>';
|
safeHTML(list, '<div class="empty-state">No activity yet.</div>');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
list.innerHTML = log
|
safeHTML(list, log
|
||||||
.slice(0, 50)
|
.slice(0, 50)
|
||||||
.map((entry) => {
|
.map((entry) => {
|
||||||
const time = new Date(entry.timestamp).toLocaleTimeString([], {
|
const time = new Date(entry.timestamp).toLocaleTimeString([], {
|
||||||
@@ -653,7 +659,7 @@ async function renderActivity() {
|
|||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
})
|
})
|
||||||
.join('');
|
.join(''));
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Test Diff (Strip: real → fake) ---
|
// --- Test Diff (Strip: real → fake) ---
|
||||||
@@ -663,7 +669,7 @@ function renderTestDiff() {
|
|||||||
const stats = $('#diffStats');
|
const stats = $('#diffStats');
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
output.innerHTML = '';
|
output.replaceChildren();
|
||||||
stats.textContent = '';
|
stats.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -703,7 +709,7 @@ function renderTestDiff() {
|
|||||||
`<span class="sub-highlight" style="background:#fee2e2;color:#dc2626" title="${escapeHtml(r.pattern)}">${escapedReplaced}</span>`
|
`<span class="sub-highlight" style="background:#fee2e2;color:#dc2626" title="${escapeHtml(r.pattern)}">${escapedReplaced}</span>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
output.innerHTML = html;
|
safeHTML(output, html);
|
||||||
|
|
||||||
const smartCount = smartResult.replacements.length;
|
const smartCount = smartResult.replacements.length;
|
||||||
const explicitCount = explicitResult.replacements.length;
|
const explicitCount = explicitResult.replacements.length;
|
||||||
@@ -723,10 +729,12 @@ function renderTestDiff() {
|
|||||||
|
|
||||||
// Show PPI warnings below stats
|
// Show PPI warnings below stats
|
||||||
if (ppiWarnings.length > 0) {
|
if (ppiWarnings.length > 0) {
|
||||||
stats.innerHTML += `<div style="margin-top:6px;padding:6px 8px;background:#fef3c7;border-radius:4px;color:#92400e;font-size:11px">
|
const ppiDiv = document.createElement('div');
|
||||||
|
safeHTML(ppiDiv, `<div style="margin-top:6px;padding:6px 8px;background:#fef3c7;border-radius:4px;color:#92400e;font-size:11px">
|
||||||
<strong>Unconfigured PPI detected:</strong>
|
<strong>Unconfigured PPI detected:</strong>
|
||||||
${ppiWarnings.map(w => `<div style="margin-top:3px"><code style="background:#fff;padding:1px 4px;border-radius:2px;color:#b45309">${escapeHtml(w.value)}</code> — ${w.hint}</div>`).join('')}
|
${ppiWarnings.map(w => `<div style="margin-top:3px"><code style="background:#fff;padding:1px 4px;border-radius:2px;color:#b45309">${escapeHtml(w.value)}</code> — ${w.hint}</div>`).join('')}
|
||||||
</div>`;
|
</div>`);
|
||||||
|
stats.appendChild(ppiDiv);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -737,7 +745,7 @@ function renderRevealDiff() {
|
|||||||
const stats = $('#revealStats');
|
const stats = $('#revealStats');
|
||||||
|
|
||||||
if (!input) {
|
if (!input) {
|
||||||
output.innerHTML = '';
|
output.replaceChildren();
|
||||||
stats.textContent = '';
|
stats.textContent = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -783,7 +791,7 @@ function renderRevealDiff() {
|
|||||||
`<span class="sub-highlight" title="Was: ${escapeHtml(pair.substitute)}" style="background:#dbeafe;color:#1d4ed8">${escapedReal}</span>`
|
`<span class="sub-highlight" title="Was: ${escapeHtml(pair.substitute)}" style="background:#dbeafe;color:#1d4ed8">${escapedReal}</span>`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
output.innerHTML = html;
|
safeHTML(output, html);
|
||||||
stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`;
|
stats.textContent = `${totalCount} value${totalCount !== 1 ? 's' : ''} revealed`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user