mirror of
https://github.com/jason5ng32/MyIP.git
synced 2026-11-04 08:55:47 +00:00
Feat(connectivity): User-managed target set with removable presets, plus multi-round latency bars
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
e757c619ea
commit
e2c0d8d1ca
15 files changed
+368
-248
No files matched your search
+4
-3
@@ -32,9 +32,10 @@ frontend/
|
||||
│ pulse-statuses (Earth Online vocabulary: presets +
|
||||
│ date-windowed festival statuses + their celebration
|
||||
│ effect mapping; recipes in utils/pulse-celebration.js)/
|
||||
│ connectivity-import-lists (curated target sets; icons
|
||||
│ are committed 64px PNGs under public/favicons/ — one
|
||||
│ per member, enforced by its data test)
|
||||
│ connectivity-import-lists (default target set +
|
||||
│ curated import sets + the shared cap; icons are
|
||||
│ committed 64px PNGs under public/favicons/ — one per
|
||||
│ member, enforced by its data test)
|
||||
├── utils/ ← framework-agnostic helpers + IO
|
||||
│ (app-events bus / getips/ / valid-ip / analytics / …)
|
||||
├── composables/ ← Vue-aware `useXxx` logic
|
||||
|
||||
@@ -22,14 +22,20 @@
|
||||
<div class="grid grid-cols-2 md:grid-cols-4 gap-3">
|
||||
<Card v-for="test in connectivityTests" :key="test.id"
|
||||
class="keyboard-shortcut-card group relative transition-transform duration-300 ease-out hover:-translate-y-1.5 data-[keyboard-hover=true]:ring-2 data-[keyboard-hover=true]:ring-green-500/50 jn-card">
|
||||
<!-- Custom-card remove button, fades in on hover -->
|
||||
<button v-if="test.custom" type="button" @click.stop="removeCustomTarget(test.id)"
|
||||
class="absolute top-1.5 right-1.5 p-1 rounded-md text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted transition-opacity cursor-pointer"
|
||||
<!-- Remove button, fades in on hover. Two-step confirm: first click
|
||||
arms "X?" (self-reverting), the second deletes; on the last
|
||||
remaining target the click just raises a toast. -->
|
||||
<button type="button" @click.stop="handleRemoveClick(test.id)"
|
||||
class="absolute top-1.5 right-1.5 p-1 rounded-md inline-flex items-center transition-opacity cursor-pointer"
|
||||
:class="confirmingRemoveId === test.id
|
||||
? 'text-destructive bg-muted'
|
||||
: 'text-muted-foreground md:opacity-0 group-hover:opacity-100 hover:text-foreground hover:bg-muted'"
|
||||
:aria-label="t('connectivity.addCustom.Remove')" :title="t('connectivity.addCustom.Remove')">
|
||||
<X class="size-3.5" />
|
||||
<span v-if="confirmingRemoveId === test.id" class="text-xs font-semibold leading-none">?</span>
|
||||
</button>
|
||||
<CardContent class="p-4">
|
||||
<!-- Site favicon (built-in / imported) or first-letter tile (custom) + name.
|
||||
<!-- Site favicon (default / imported) or first-letter tile (custom) + name.
|
||||
Favicons are same-origin (public/favicons/), so they render even
|
||||
when the tested site is unreachable; a load error falls back to
|
||||
the letter tile. -->
|
||||
@@ -61,13 +67,14 @@
|
||||
{{ test.time }}<span class="ml-0.5 text-sm">ms</span>
|
||||
</span>
|
||||
</div>
|
||||
<!-- Multi-test per-round progress dots (historical snapshot). -->
|
||||
<div v-if="multipleTests" class="flex items-center gap-1 mt-2">
|
||||
<!-- Multi-test per-round latency bars, all on one absolute scale
|
||||
(taller = slower, see barStyle); failed / pending rounds are
|
||||
short gray stubs. -->
|
||||
<div v-if="multipleTests" class="flex items-end gap-1 mt-2 h-4">
|
||||
<JnTooltip v-for="i in totalRounds" :key="i" :text="roundTooltipText(test, i - 1)" side="top">
|
||||
<span class="group/dot inline-flex items-center justify-center p-1 -m-1 cursor-default">
|
||||
<span
|
||||
class="size-1.5 rounded-full md:transition-transform md:duration-200 md:group-hover/dot:scale-[2.0]"
|
||||
:class="progressDotClass(test, i - 1)"></span>
|
||||
<span class="group/bar inline-flex h-full items-end justify-center px-1 -mx-1 cursor-default">
|
||||
<span class="w-1.5 rounded-[2px] md:transition-[height,filter] md:duration-200 md:group-hover/bar:brightness-125"
|
||||
:class="barClass(test, i - 1)" :style="barStyle(test, i - 1)"></span>
|
||||
</span>
|
||||
</JnTooltip>
|
||||
</div>
|
||||
@@ -75,13 +82,12 @@
|
||||
</Card>
|
||||
|
||||
<!-- "Add Test" tile: stacked flag/brand icons signal that curated
|
||||
lists live behind it, not just a blank form. Always visible —
|
||||
at the cap the dialog is still the way to remove imported lists. -->
|
||||
lists live behind it, not just a blank form. -->
|
||||
<Card @click="addDialogOpen = true"
|
||||
class="cursor-pointer border-dashed bg-transparent hover:bg-muted/50 transition-colors"
|
||||
:title="t('connectivity.addCustom.AddCard')">
|
||||
<CardContent class="p-4 flex flex-col items-center justify-center gap-2 text-muted-foreground"
|
||||
:class="multipleTests ? 'min-h-[106px]' : 'min-h-[92px]'">
|
||||
:class="multipleTests ? 'min-h-29' : 'min-h-23'">
|
||||
<span class="flex -space-x-2">
|
||||
<template v-for="item in TILE_PREVIEW" :key="item.emoji || item.id">
|
||||
<span v-if="item.type === 'emoji'"
|
||||
@@ -99,7 +105,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Add / import dialog (custom form + curated list browser) -->
|
||||
<ConnectivityAddDialog v-model:open="addDialogOpen" :builtin-urls="builtinUrls" />
|
||||
<ConnectivityAddDialog v-model:open="addDialogOpen" />
|
||||
</section>
|
||||
</template>
|
||||
|
||||
@@ -111,6 +117,7 @@ import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import { CONNECTIVITY_STATUS } from '@/utils/report-schema.js';
|
||||
import { TILE_PREVIEW, faviconPath } from '@/data/connectivity-import-lists.js';
|
||||
import { removeTarget } from '@/utils/connectivity-import.js';
|
||||
import { JnTooltip } from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
@@ -141,71 +148,42 @@ const totalRounds = computed(() => 1 + maxCounts.value);
|
||||
const allRoundsDone = ref(false);
|
||||
const alertFired = ref(false);
|
||||
|
||||
// Built-in targets first; custom / imported ones (`.custom = true`) are
|
||||
// merged in by the watcher below. Keeping both in one reactive array lets
|
||||
// every iterator (run-loop, keyboard nav) treat tiles uniformly.
|
||||
// `roundResults` records per-round { tone, time } for the progress dots,
|
||||
// independent of the best-of-N face/text. `time` powers the per-dot hover
|
||||
// tooltip; for `tone: 'fail'` rounds it stays 0. Bootstrap-only writer.
|
||||
// Icons come from the committed same-origin favicon set (public/favicons/).
|
||||
const builtinTarget = (id, name, url) => ({
|
||||
id, name, url, favicon: faviconPath(id), status: t('connectivity.StatusWait'), time: 0, mintime: 0, roundResults: [],
|
||||
// One reactive test entry per stored target (the connectivityTargets
|
||||
// preference holds the whole set, defaults included). `roundResults`
|
||||
// records per-round { tone, time } for the latency bars; icons come from
|
||||
// the committed favicon set, hand-added targets fall back to letter tiles.
|
||||
const targetEntry = (target) => reactive({
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
url: target.url,
|
||||
favicon: target.favicon || null,
|
||||
status: t('connectivity.StatusWait'),
|
||||
time: 0,
|
||||
mintime: 0,
|
||||
roundResults: [],
|
||||
});
|
||||
const connectivityTests = reactive([
|
||||
builtinTarget('google', 'Google', 'https://www.google.com/favicon.ico'),
|
||||
builtinTarget('youtube', 'YouTube', 'https://www.youtube.com/favicon.ico'),
|
||||
builtinTarget('github', 'GitHub', 'https://github.com/favicon.ico'),
|
||||
// Use speed.cloudflare.com (not www.cloudflare.com): the marketing site
|
||||
// attaches `Link: rel=preload` headers for its brand fonts to every response
|
||||
// — including /favicon.ico — which the browser honors regardless of fetch
|
||||
// mode, then fails CORS on the woff2 files. speed.cloudflare.com serves a
|
||||
// plain favicon with no preload headers.
|
||||
builtinTarget('cloudflare', 'Cloudflare', 'https://speed.cloudflare.com/favicon.ico'),
|
||||
builtinTarget('claude', 'Claude', 'https://claude.com/favicon.ico'),
|
||||
builtinTarget('chatgpt', 'ChatGPT', 'https://chatgpt.com/favicon.ico'),
|
||||
builtinTarget('wechat', 'WeChat', 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico'),
|
||||
]);
|
||||
const connectivityTests = reactive([]);
|
||||
|
||||
// Hostname-dedupe input for the import dialog.
|
||||
const builtinUrls = connectivityTests.map((test) => test.url);
|
||||
|
||||
// Reconcile custom targets by id (not wipe-and-refill) so existing cards
|
||||
// don't flash back to "Awaiting Test" each time the user adds another one.
|
||||
// Reconcile by id (not wipe-and-refill) so existing cards don't flash back
|
||||
// to "Awaiting Test" each time the user adds or removes another one.
|
||||
watch(
|
||||
() => userPreferences.value.customConnectivityTargets,
|
||||
() => userPreferences.value.connectivityTargets,
|
||||
(newTargets) => {
|
||||
const targets = newTargets || [];
|
||||
const targetIds = new Set(targets.map((t) => t.id));
|
||||
|
||||
// Drop customs no longer in storage.
|
||||
// Drop targets no longer in storage.
|
||||
for (let i = connectivityTests.length - 1; i >= 0; i--) {
|
||||
const entry = connectivityTests[i];
|
||||
if (entry.custom && !targetIds.has(entry.id)) {
|
||||
if (!targetIds.has(connectivityTests[i].id)) {
|
||||
connectivityTests.splice(i, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Push only newcomers.
|
||||
const existingCustomIds = new Set(
|
||||
connectivityTests.filter((entry) => entry.custom).map((entry) => entry.id),
|
||||
);
|
||||
|
||||
const existingIds = new Set(connectivityTests.map((entry) => entry.id));
|
||||
for (const target of targets) {
|
||||
if (existingCustomIds.has(target.id)) continue;
|
||||
const entry = reactive({
|
||||
id: target.id,
|
||||
name: target.name,
|
||||
url: target.url,
|
||||
custom: true,
|
||||
// Imported entries carry a same-origin favicon + their source list;
|
||||
// hand-added ones fall back to the letter tile.
|
||||
favicon: target.favicon || null,
|
||||
listId: target.listId || null,
|
||||
status: t('connectivity.StatusWait'),
|
||||
time: 0,
|
||||
mintime: 0,
|
||||
roundResults: [],
|
||||
});
|
||||
if (existingIds.has(target.id)) continue;
|
||||
const entry = targetEntry(target);
|
||||
connectivityTests.push(entry);
|
||||
// Cards added after the bootstrap pass (import / hand-add) test
|
||||
// themselves right away instead of sitting at "Awaiting Test".
|
||||
@@ -228,13 +206,38 @@ const toneOf = (test) => {
|
||||
};
|
||||
const { dotClass, textClass } = useStatusTone();
|
||||
|
||||
// Filled → tone color; head-of-queue → pulse (suppressed once the user
|
||||
// takes over manually so we don't promise rounds that won't land); rest → dim.
|
||||
const progressDotClass = (test, idx) => {
|
||||
// Bar color, by absolute latency bucket: green <200ms, yellow <1000ms, red
|
||||
// beyond (extreme latency shares the fail tone), solid gray for
|
||||
// unreachable rounds. Pending bars stay faint; head-of-queue pulses
|
||||
// (suppressed once the user takes over manually).
|
||||
const BAR_RED_MS = 1000;
|
||||
const barClass = (test, idx) => {
|
||||
const entry = test.roundResults[idx];
|
||||
if (entry) return dotClass(entry.tone);
|
||||
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
|
||||
return 'bg-muted-foreground/20';
|
||||
if (!entry) {
|
||||
if (!manualRun.value && idx === test.roundResults.length) return 'bg-muted-foreground/40 animate-pulse';
|
||||
return 'bg-muted-foreground/20';
|
||||
}
|
||||
if (entry.tone === 'fail') return 'bg-muted-foreground';
|
||||
if (entry.time < 200) return dotClass('ok-fast');
|
||||
if (entry.time < BAR_RED_MS) return dotClass('ok-slow');
|
||||
return dotClass('fail');
|
||||
};
|
||||
|
||||
// Bar height, on the card's own dynamic range: the fastest round sits at
|
||||
// the floor (kept hoverable), the slowest at 100%, the rest on a log curve
|
||||
// between — rising latency compresses into ever-smaller height steps, and
|
||||
// the top end is told apart by color instead. Failed and pending rounds
|
||||
// have no latency and sit at the floor.
|
||||
const BAR_FLOOR = 25;
|
||||
const barStyle = (test, idx) => {
|
||||
const entry = test.roundResults[idx];
|
||||
if (!entry || !entry.time) return { height: `${BAR_FLOOR}%` };
|
||||
const times = test.roundResults.map((r) => r.time).filter((v) => v > 0);
|
||||
const min = Math.min(...times);
|
||||
const max = Math.max(...times);
|
||||
if (max === min) return { height: '100%' };
|
||||
const p = Math.log(entry.time / min) / Math.log(max / min);
|
||||
return { height: `${Math.round(BAR_FLOOR + (100 - BAR_FLOOR) * p)}%` };
|
||||
};
|
||||
|
||||
// Per-dot hover tooltip: empty string disables the tooltip (JnTooltip's
|
||||
@@ -344,7 +347,7 @@ const checkAllConnectivity = (isAlertToShow, isRefresh, isManualRun) => {
|
||||
targets: connectivityTests.map((test) => ({
|
||||
id: test.id,
|
||||
name: test.name,
|
||||
custom: test.custom === true,
|
||||
custom: test.id.startsWith('custom-'),
|
||||
statusCode: test.statusCode,
|
||||
time: test.time,
|
||||
mintime: test.mintime,
|
||||
@@ -380,20 +383,40 @@ const updateConnectivityAlert = (type) => {
|
||||
}
|
||||
};
|
||||
|
||||
// ── Add/remove custom targets ──────────────────────────────────────────────
|
||||
// ── Add/remove targets ─────────────────────────────────────────────────────
|
||||
// The add/import dialog itself lives in ConnectivityAddDialog.vue; this
|
||||
// component only owns the open flag and per-card removal.
|
||||
const addDialogOpen = ref(false);
|
||||
|
||||
const removeCustomTarget = (id) => {
|
||||
const current = userPreferences.value.customConnectivityTargets || [];
|
||||
store.updatePreference(
|
||||
'customConnectivityTargets',
|
||||
current.filter((t) => t.id !== id),
|
||||
);
|
||||
const removeTargetById = (id) => {
|
||||
const result = removeTarget(userPreferences.value.connectivityTargets || [], id);
|
||||
if (result.error) return;
|
||||
store.updatePreference('connectivityTargets', result.targets);
|
||||
trackEvent('Section', 'RemoveCustomTarget', 'Connectivity');
|
||||
};
|
||||
|
||||
// Two-step removal: first click arms "X?" (self-reverting), second click
|
||||
// deletes. The last remaining target skips the dance — the set must never
|
||||
// go empty, so the click just explains itself via toast.
|
||||
const confirmingRemoveId = ref(null);
|
||||
let confirmResetTimer = null;
|
||||
const handleRemoveClick = (id) => {
|
||||
clearTimeout(confirmResetTimer);
|
||||
if ((userPreferences.value.connectivityTargets || []).length <= 1) {
|
||||
confirmingRemoveId.value = null;
|
||||
store.setAlert(true, 'text-warning',
|
||||
t('connectivity.addCustom.LastTargetMessage'), t('connectivity.addCustom.LastTargetTitle'));
|
||||
return;
|
||||
}
|
||||
if (confirmingRemoveId.value === id) {
|
||||
confirmingRemoveId.value = null;
|
||||
removeTargetById(id);
|
||||
return;
|
||||
}
|
||||
confirmingRemoveId.value = id;
|
||||
confirmResetTimer = setTimeout(() => { confirmingRemoveId.value = null; }, 3000);
|
||||
};
|
||||
|
||||
// Multi-mode aggregate: `mintime > 0` ≡ reachable in at least one round.
|
||||
const finalizeMultiTestAlert = () => {
|
||||
const total = connectivityTests.length;
|
||||
@@ -459,12 +482,13 @@ onMounted(() => {
|
||||
store.setMountingStatus('Connectivity', true);
|
||||
});
|
||||
|
||||
// Stop the interval on unmount.
|
||||
// Stop the interval (and any pending confirm revert) on unmount.
|
||||
onBeforeUnmount(() => {
|
||||
if (intervalId.value !== null) {
|
||||
clearInterval(intervalId.value);
|
||||
intervalId.value = null;
|
||||
}
|
||||
clearTimeout(confirmResetTimer);
|
||||
});
|
||||
|
||||
// Either signal flipping fires sendAlert; the gates inside pick the winner.
|
||||
|
||||
@@ -13,22 +13,18 @@
|
||||
{{ t('connectivity.importDialog.CustomOption') }}</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<!-- Curated lists: one card per list, two columns on desktop;
|
||||
import / remove in place. -->
|
||||
<!-- Curated lists, two columns on desktop; the system defaults
|
||||
close the grid as a recovery entry. Lists may overlap —
|
||||
hostname dedupe on import keeps cards unique. -->
|
||||
<TabsContent value="import" class="space-y-3">
|
||||
<ul class="grid gap-2 md:grid-cols-2 max-h-[55vh] overflow-y-auto pr-1">
|
||||
<li v-for="list in IMPORT_LISTS" :key="list.id"
|
||||
<li v-for="list in importableLists" :key="list.id"
|
||||
class="flex items-start gap-3 p-3 rounded-lg border bg-card">
|
||||
<span class="size-6 shrink-0 inline-flex items-center justify-center text-lg leading-none">
|
||||
{{ list.emoji }}</span>
|
||||
<span class="flex min-w-0 flex-1 flex-col gap-0.5">
|
||||
<!-- The inline check is the imported-state marker; the action
|
||||
column stays a single icon either way. -->
|
||||
<span class="flex items-center gap-1.5 min-w-0">
|
||||
<span class="truncate" :title="t('connectivity.importLists.' + list.id)">
|
||||
{{ t('connectivity.importLists.' + list.id) }}</span>
|
||||
<Check v-if="importedIds.has(list.id)" class="size-3.5 shrink-0 text-success" />
|
||||
</span>
|
||||
<span class="truncate min-w-0" :title="t('connectivity.importLists.' + list.id)">
|
||||
{{ t('connectivity.importLists.' + list.id) }}</span>
|
||||
<span class="flex items-center gap-2">
|
||||
<span class="flex -space-x-1.5">
|
||||
<img v-for="m in list.members.slice(0, 4)" :key="m.id" :src="faviconPath(m.id)" alt=""
|
||||
@@ -38,25 +34,25 @@
|
||||
{{ t('connectivity.importDialog.SiteCount', { n: list.members.length }) }}</span>
|
||||
</span>
|
||||
</span>
|
||||
<Button v-if="!importedIds.has(list.id)" size="icon" variant="outline"
|
||||
<!-- Fully present → the import button itself becomes an inert
|
||||
green check, so the action column never goes empty. -->
|
||||
<Button v-if="!isListFullyPresent(list, targets)" size="icon" variant="outline"
|
||||
class="size-8 shrink-0 cursor-pointer" @click="importList(list)"
|
||||
:title="t('connectivity.importDialog.Import')" :aria-label="t('connectivity.importDialog.Import')">
|
||||
<Plus class="size-4" />
|
||||
</Button>
|
||||
<Button v-else size="icon" variant="ghost"
|
||||
class="size-8 shrink-0 cursor-pointer text-muted-foreground hover:text-destructive"
|
||||
@click="removeList(list)" :title="t('connectivity.importDialog.RemoveList')"
|
||||
:aria-label="t('connectivity.importDialog.RemoveList')">
|
||||
<X class="size-4" />
|
||||
<Button v-else size="icon" variant="ghost" disabled
|
||||
class="size-8 shrink-0 text-success disabled:opacity-100">
|
||||
<Check class="size-4" />
|
||||
</Button>
|
||||
</li>
|
||||
</ul>
|
||||
<!-- Success stays quiet (the inline check is the confirmation);
|
||||
only a cap overflow surfaces a message here. -->
|
||||
<!-- Success stays quiet (the button turning into a check is the
|
||||
confirmation); only a cap overflow surfaces a message here. -->
|
||||
<div class="flex flex-col items-end justify-end gap-1 min-h-4 text-xs text-muted-foreground">
|
||||
<span aria-live="polite" class="text-destructive">{{ importResult || ' ' }}</span>
|
||||
<span class="tabular-nums shrink-0">
|
||||
{{ t('connectivity.importDialog.Capacity', { used: customTargets.length, limit: CONNECTIVITY_TARGET_LIMIT
|
||||
{{ t('connectivity.importDialog.Capacity', { used: targets.length, limit: CONNECTIVITY_TARGET_LIMIT
|
||||
}) }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -99,35 +95,35 @@
|
||||
// curated country/theme list (default tab, matching the tile's stacked-icon
|
||||
// cue), or hand-add a single custom target (migrated from
|
||||
// ConnectivityTest.vue). Imports materialize list members into the
|
||||
// customConnectivityTargets preference tagged with listId — imported cards
|
||||
// then behave exactly like hand-added ones (individually removable); this
|
||||
// dialog is also where a whole list gets removed again.
|
||||
// connectivityTargets preference — imported cards then behave exactly like
|
||||
// hand-added ones (individually removable in the grid).
|
||||
import { ref, computed, watch, nextTick } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { useMainStore } from '@/store';
|
||||
import { trackEvent } from '@/utils/analytics';
|
||||
import { emitAppEvent } from '@/utils/app-events';
|
||||
import {
|
||||
IMPORT_LISTS, CONNECTIVITY_TARGET_LIMIT, faviconPath,
|
||||
IMPORT_LISTS, SYSTEM_IMPORT_LIST, CONNECTIVITY_TARGET_LIMIT, faviconPath,
|
||||
} from '@/data/connectivity-import-lists.js';
|
||||
import { planImport, importedListIds, withoutList } from '@/utils/connectivity-import.js';
|
||||
import { planImport, isListFullyPresent } from '@/utils/connectivity-import.js';
|
||||
import { Dialog, DialogContent, DialogHeader } from '@/components/ui/dialog';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Check, CirclePlus, Plus, X } from '@lucide/vue';
|
||||
import { Check, CirclePlus, Plus } from '@lucide/vue';
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
// Built-in target URLs, for hostname dedupe during import planning.
|
||||
builtinUrls: { type: Array, default: () => [] },
|
||||
});
|
||||
const emit = defineEmits(['update:open']);
|
||||
|
||||
const { t } = useI18n();
|
||||
const store = useMainStore();
|
||||
const customTargets = computed(() => store.userPreferences.customConnectivityTargets || []);
|
||||
const targets = computed(() => store.userPreferences.connectivityTargets || []);
|
||||
|
||||
// System defaults last — a recovery entry, not a suggestion.
|
||||
const importableLists = [...IMPORT_LISTS, SYSTEM_IMPORT_LIST];
|
||||
|
||||
const tab = ref('import');
|
||||
const importResult = ref('');
|
||||
@@ -148,13 +144,8 @@ watch(tab, (v) => {
|
||||
});
|
||||
|
||||
// ── Curated lists ──────────────────────────────────────────────────────────
|
||||
const importedIds = computed(() => importedListIds(customTargets.value));
|
||||
|
||||
const importList = (list) => {
|
||||
const plan = planImport(list, {
|
||||
existingUrls: [...props.builtinUrls, ...customTargets.value.map((c) => c.url)],
|
||||
currentCount: customTargets.value.length,
|
||||
});
|
||||
const plan = planImport(list, targets.value);
|
||||
// All-or-nothing (enforced in planImport): a capacity shortfall stores
|
||||
// nothing, so the list is never half-imported behind a complete-looking ✓.
|
||||
if (plan.overflowCount) {
|
||||
@@ -164,18 +155,12 @@ const importList = (list) => {
|
||||
});
|
||||
return;
|
||||
}
|
||||
store.updatePreference('customConnectivityTargets', [...customTargets.value, ...plan.additions]);
|
||||
// Quiet on success — the inline check marks the list.
|
||||
store.updatePreference('connectivityTargets', [...targets.value, ...plan.additions]);
|
||||
// Quiet on success — the button flips to a check.
|
||||
importResult.value = '';
|
||||
trackEvent('Section', 'ImportList', list.id);
|
||||
};
|
||||
|
||||
const removeList = (list) => {
|
||||
store.updatePreference('customConnectivityTargets', withoutList(customTargets.value, list.id));
|
||||
importResult.value = '';
|
||||
trackEvent('Section', 'RemoveImportList', list.id);
|
||||
};
|
||||
|
||||
// ── Custom add (migrated intact from ConnectivityTest.vue) ────────────────
|
||||
const addName = ref('');
|
||||
const addUrl = ref('');
|
||||
@@ -227,7 +212,7 @@ const handleAdd = () => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (customTargets.value.length >= CONNECTIVITY_TARGET_LIMIT) {
|
||||
if (targets.value.length >= CONNECTIVITY_TARGET_LIMIT) {
|
||||
addError.value = t('connectivity.addCustom.LimitReached');
|
||||
return;
|
||||
}
|
||||
@@ -237,7 +222,7 @@ const handleAdd = () => {
|
||||
name: name.slice(0, 20),
|
||||
url,
|
||||
};
|
||||
store.updatePreference('customConnectivityTargets', [...customTargets.value, newTarget]);
|
||||
store.updatePreference('connectivityTargets', [...targets.value, newTarget]);
|
||||
trackEvent('Section', 'AddCustomTarget', 'Connectivity');
|
||||
// Hand-add only — importList() deliberately doesn't emit this.
|
||||
emitAppEvent('connectivity:custom-added', { name: newTarget.name, url: newTarget.url });
|
||||
|
||||
@@ -1611,10 +1611,19 @@
|
||||
{
|
||||
"type": "add",
|
||||
"change": {
|
||||
"en": "Connectivity: new importable list — global e-commerce platforms (Amazon, AliExpress, Temu, SHEIN and more)",
|
||||
"zh": "网络连通性新增可导入列表:国际电商平台(Amazon、AliExpress、Temu、SHEIN 等)",
|
||||
"fr": "Connectivité : nouvelle liste importable — plateformes e-commerce internationales (Amazon, AliExpress, Temu, SHEIN et plus)",
|
||||
"ru": "Проверка доступности: новый список для импорта — международные площадки электронной коммерции (Amazon, AliExpress, Temu, SHEIN и другие)"
|
||||
"en": "Connectivity: preset sites are now removable, plus a new Global E-commerce import list",
|
||||
"zh": "网络连通性测试可删除预设网站、新增国际电商平台导入列表",
|
||||
"fr": "Connectivité : les sites prédéfinis sont désormais supprimables, avec une nouvelle liste importable « E-commerce mondial »",
|
||||
"ru": "Проверка доступности: предустановленные сайты теперь можно удалять, добавлен импортируемый список «Электронная коммерция»"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "improve",
|
||||
"change": {
|
||||
"en": "Connectivity: multi-round test results now render as beautiful latency bars",
|
||||
"zh": "使用精美的柱状图展示多次刷新模式下的网络连通性测试结果",
|
||||
"fr": "Connectivité : les résultats des tests multi-passes s'affichent désormais en élégantes barres de latence",
|
||||
"ru": "Проверка доступности: результаты многократных тестов теперь отображаются изящными столбиками задержки"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -1,35 +1,54 @@
|
||||
// Curated importable target lists for the Connectivity section: country sets
|
||||
// (domestic reachability) and theme sets (global services). Importing
|
||||
// materializes members into the user's customConnectivityTargets preference
|
||||
// (tagged with listId), so imported cards behave exactly like hand-added
|
||||
// ones. Display names live in the locale packs under
|
||||
// `connectivity.importLists.<id>`; member names are brand names and stay
|
||||
// untranslated. Every member ships a committed 64px PNG at
|
||||
// public/favicons/<id>.png (the data test enforces it) — same-origin, so
|
||||
// icons render even when the tested site is blocked for the visitor.
|
||||
// materializes members into the user's connectivityTargets preference, so
|
||||
// imported cards behave exactly like hand-added ones. Display names live in
|
||||
// the locale packs under `connectivity.importLists.<id>`; member names are
|
||||
// brand names and stay untranslated. Every member ships a committed 64px
|
||||
// PNG at public/favicons/<id>.png (the data test enforces it) —
|
||||
// same-origin, so icons render even when the tested site is blocked for
|
||||
// the visitor.
|
||||
|
||||
// Total cap shared by hand-added and imported targets. The grid and the
|
||||
// multi-round test loop stay usable at this scale; the import dialog
|
||||
// surfaces remaining headroom.
|
||||
// Total cap on stored targets, defaults included; enforced at add/import
|
||||
// time only, so an over-cap migrated set keeps working but can't grow.
|
||||
export const CONNECTIVITY_TARGET_LIMIT = 60;
|
||||
|
||||
// Same-origin favicon path for a member id (fetched at dev time).
|
||||
export const faviconPath = (id) => `/favicons/${id}.png`;
|
||||
|
||||
// The seven built-in targets render through the same favicon pipeline as
|
||||
// imported members (ConnectivityTest.vue maps these ids onto its tiles).
|
||||
// `iconDomain` feeds scripts/fetch-favicons.js — it differs from the test
|
||||
// URL's host where that host serves a poor or missing icon.
|
||||
export const BUILTIN_FAVICONS = [
|
||||
{ id: 'google', iconDomain: 'www.google.com' },
|
||||
{ id: 'youtube', iconDomain: 'www.youtube.com' },
|
||||
{ id: 'github', iconDomain: 'github.com' },
|
||||
{ id: 'cloudflare', iconDomain: 'www.cloudflare.com' },
|
||||
{ id: 'claude', iconDomain: 'claude.com' },
|
||||
{ id: 'chatgpt', iconDomain: 'chatgpt.com' },
|
||||
{ id: 'wechat', iconDomain: 'weixin.qq.com' },
|
||||
// Targets every fresh install starts with (migration appends legacy
|
||||
// customs); all removable once stored. Cloudflare tests
|
||||
// speed.cloudflare.com, not www: the marketing site attaches font-preload
|
||||
// headers to every response, and those preloads then fail CORS.
|
||||
export const DEFAULT_LIST_MEMBERS = [
|
||||
{ id: 'google', name: 'Google', url: 'https://www.google.com/favicon.ico' },
|
||||
{ id: 'youtube', name: 'YouTube', url: 'https://www.youtube.com/favicon.ico' },
|
||||
{ id: 'github', name: 'GitHub', url: 'https://github.com/favicon.ico' },
|
||||
{ id: 'cloudflare', name: 'Cloudflare', url: 'https://speed.cloudflare.com/favicon.ico', iconDomain: 'www.cloudflare.com' },
|
||||
{ id: 'claude', name: 'Claude', url: 'https://claude.com/favicon.ico' },
|
||||
{ id: 'chatgpt', name: 'ChatGPT', url: 'https://chatgpt.com/favicon.ico' },
|
||||
{ id: 'wechat', name: 'WeChat', url: 'https://res.wx.qq.com/a/wx_fed/assets/res/NTI4MWU5.ico', iconDomain: 'weixin.qq.com' },
|
||||
];
|
||||
|
||||
// Favicon-fetch targets for scripts/fetch-favicons.js. `iconDomain` differs
|
||||
// from the test URL's host where that host serves a poor or missing icon.
|
||||
export const BUILTIN_FAVICONS = DEFAULT_LIST_MEMBERS.map((m) => ({
|
||||
id: m.id,
|
||||
iconDomain: m.iconDomain || new URL(m.url).hostname,
|
||||
}));
|
||||
|
||||
// The defaults as an importable list (shown last in the dialog) to bring
|
||||
// deleted default sites back. Not in IMPORT_LISTS: below its size floor.
|
||||
export const SYSTEM_IMPORT_LIST = {
|
||||
id: 'defaults',
|
||||
emoji: '🚦',
|
||||
members: DEFAULT_LIST_MEMBERS,
|
||||
};
|
||||
|
||||
// Defaults that also belong in a themed list are referenced, not
|
||||
// re-declared — themed lists stay complete on their own (AI ⊃ ChatGPT);
|
||||
// hostname dedupe on import keeps cards unique.
|
||||
const builtinMember = (id) => DEFAULT_LIST_MEMBERS.find((m) => m.id === id);
|
||||
|
||||
// Every list is fronted by an emoji (flag emoji for country lists) — one
|
||||
// uniform icon system, no icon-font dependency in this data.
|
||||
// Member shape: { id, name, url } (+ optional `iconDomain` when the test
|
||||
@@ -72,6 +91,7 @@ export const IMPORT_LISTS = [
|
||||
id: 'china',
|
||||
emoji: '🇨🇳',
|
||||
members: [
|
||||
builtinMember('wechat'),
|
||||
{ id: 'baidu', name: 'Baidu', url: 'https://www.baidu.com/favicon.ico' },
|
||||
{ id: 'taobao', name: 'Taobao', url: 'https://www.taobao.com/favicon.ico' },
|
||||
{ id: 'bilibili', name: 'Bilibili', url: 'https://www.bilibili.com/favicon.ico' },
|
||||
@@ -89,6 +109,8 @@ export const IMPORT_LISTS = [
|
||||
id: 'ai',
|
||||
emoji: '🤖',
|
||||
members: [
|
||||
builtinMember('claude'),
|
||||
builtinMember('chatgpt'),
|
||||
{ id: 'gemini', name: 'Gemini', url: 'https://gemini.google.com/favicon.ico' },
|
||||
{ id: 'deepseek', name: 'DeepSeek', url: 'https://chat.deepseek.com/favicon.ico', iconDomain: 'www.deepseek.com' },
|
||||
{ id: 'huggingface', name: 'Hugging Face', url: 'https://huggingface.co/favicon.ico' },
|
||||
@@ -125,6 +147,7 @@ export const IMPORT_LISTS = [
|
||||
id: 'streaming',
|
||||
emoji: '🎬',
|
||||
members: [
|
||||
builtinMember('youtube'),
|
||||
{ id: 'netflix', name: 'Netflix', url: 'https://www.netflix.com/favicon.ico' },
|
||||
{ id: 'spotify', name: 'Spotify', url: 'https://open.spotify.com/favicon.ico' },
|
||||
{ id: 'tiktok', name: 'TikTok', url: 'https://www.tiktok.com/favicon.ico' },
|
||||
@@ -160,6 +183,7 @@ export const IMPORT_LISTS = [
|
||||
id: 'developer',
|
||||
emoji: '👨💻',
|
||||
members: [
|
||||
builtinMember('github'),
|
||||
{ id: 'npm', name: 'npm', url: 'https://registry.npmjs.org/favicon.ico', iconDomain: 'www.npmjs.com' },
|
||||
{ id: 'dockerhub', name: 'Docker Hub', url: 'https://hub.docker.com/favicon.ico', iconDomain: 'www.docker.com' },
|
||||
{ id: 'pypi', name: 'PyPI', url: 'https://pypi.org/favicon.ico' },
|
||||
@@ -178,6 +202,7 @@ export const IMPORT_LISTS = [
|
||||
id: 'cloud',
|
||||
emoji: '☁️',
|
||||
members: [
|
||||
builtinMember('cloudflare'),
|
||||
{ id: 'aws', name: 'AWS', url: 'https://aws.amazon.com/favicon.ico' },
|
||||
{ id: 'azure', name: 'Azure', url: 'https://azure.microsoft.com/favicon.ico' },
|
||||
{ id: 'gcloud', name: 'Google Cloud', url: 'https://cloud.google.com/favicon.ico' },
|
||||
|
||||
@@ -27,9 +27,11 @@ export const DEFAULT_PREFERENCES = Object.freeze({
|
||||
ipHistoryEnabled: true,
|
||||
ipHistoryDays: 90,
|
||||
lang: 'auto', // auto | zh | en | fr | tr
|
||||
// User-defined extra targets for the Connectivity test grid. Each entry:
|
||||
// { id: 'custom-<timestamp>', name: string, url: string-with-trailing-? }
|
||||
// See ConnectivityTest.vue for how these are merged with the built-in list.
|
||||
// The full Connectivity target set (defaults included). null until first
|
||||
// load — store.loadPreferences() builds it via buildInitialTargets().
|
||||
connectivityTargets: null,
|
||||
// Legacy pre-connectivityTargets key. Read once by the build above, kept
|
||||
// for rollback; never written.
|
||||
customConnectivityTargets: [],
|
||||
});
|
||||
|
||||
|
||||
@@ -1216,7 +1216,9 @@
|
||||
"NameRequired": "Please enter a name.",
|
||||
"UrlRequired": "Please enter a URL.",
|
||||
"InvalidUrl": "That doesn't look like a valid URL.",
|
||||
"LimitReached": "You've reached the 60 tests limit."
|
||||
"LimitReached": "You've reached the 60 tests limit.",
|
||||
"LastTargetTitle": "Can't Remove",
|
||||
"LastTargetMessage": "At least one test site must remain."
|
||||
},
|
||||
"importDialog": {
|
||||
"Title": "Add / Import Tests",
|
||||
@@ -1224,11 +1226,11 @@
|
||||
"ImportOption": "Import a List",
|
||||
"SiteCount": "{n} sites",
|
||||
"Import": "Import",
|
||||
"RemoveList": "Remove this list",
|
||||
"Capacity": "{used}/{limit} used",
|
||||
"NotEnoughCapacity": "Not enough capacity — this list needs {need} slots, only {free} left."
|
||||
},
|
||||
"importLists": {
|
||||
"defaults": "System Defaults",
|
||||
"iran": "Iran Essentials",
|
||||
"russia": "Russia Essentials",
|
||||
"china": "China Essentials",
|
||||
|
||||
@@ -1216,7 +1216,9 @@
|
||||
"NameRequired": "Veuillez saisir un nom.",
|
||||
"UrlRequired": "Veuillez saisir une URL.",
|
||||
"InvalidUrl": "Cela ne ressemble pas à une URL valide.",
|
||||
"LimitReached": "Limite de 60 tests atteinte."
|
||||
"LimitReached": "Limite de 60 tests atteinte.",
|
||||
"LastTargetTitle": "Suppression impossible",
|
||||
"LastTargetMessage": "Au moins un site de test doit rester."
|
||||
},
|
||||
"importDialog": {
|
||||
"Title": "Ajouter / importer des tests",
|
||||
@@ -1224,11 +1226,11 @@
|
||||
"ImportOption": "Importer une liste",
|
||||
"SiteCount": "{n} sites",
|
||||
"Import": "Importer",
|
||||
"RemoveList": "Retirer cette liste",
|
||||
"Capacity": "{used}/{limit} utilisés",
|
||||
"NotEnoughCapacity": "Capacité insuffisante — cette liste nécessite {need} emplacements, seulement {free} restants."
|
||||
},
|
||||
"importLists": {
|
||||
"defaults": "Sites par défaut",
|
||||
"iran": "Essentiels Iran",
|
||||
"russia": "Essentiels Russie",
|
||||
"china": "Essentiels Chine",
|
||||
|
||||
@@ -1216,7 +1216,9 @@
|
||||
"NameRequired": "Введите название.",
|
||||
"UrlRequired": "Введите URL.",
|
||||
"InvalidUrl": "Похоже, это недействительный URL.",
|
||||
"LimitReached": "Достигнут лимит в 60 тестов."
|
||||
"LimitReached": "Достигнут лимит в 60 тестов.",
|
||||
"LastTargetTitle": "Нельзя удалить",
|
||||
"LastTargetMessage": "Должен остаться хотя бы один сайт для теста."
|
||||
},
|
||||
"importDialog": {
|
||||
"Title": "Добавить / импортировать тесты",
|
||||
@@ -1224,11 +1226,11 @@
|
||||
"ImportOption": "Импорт списка",
|
||||
"SiteCount": "Сайтов: {n}",
|
||||
"Import": "Импорт",
|
||||
"RemoveList": "Удалить этот список",
|
||||
"Capacity": "Использовано {used}/{limit}",
|
||||
"NotEnoughCapacity": "Недостаточно места — для списка нужно слотов: {need}, свободно: {free}."
|
||||
},
|
||||
"importLists": {
|
||||
"defaults": "Системный набор",
|
||||
"iran": "Иран: основные",
|
||||
"russia": "Россия: основные",
|
||||
"china": "Китай: основные",
|
||||
|
||||
@@ -1216,7 +1216,9 @@
|
||||
"NameRequired": "请输入名称",
|
||||
"UrlRequired": "请输入 URL",
|
||||
"InvalidUrl": "这看起来不是一个有效的 URL",
|
||||
"LimitReached": "测试数量已达到 60 个上限"
|
||||
"LimitReached": "测试数量已达到 60 个上限",
|
||||
"LastTargetTitle": "无法删除",
|
||||
"LastTargetMessage": "至少需要保留一个测试网站。"
|
||||
},
|
||||
"importDialog": {
|
||||
"Title": "添加 / 导入测试",
|
||||
@@ -1224,11 +1226,11 @@
|
||||
"ImportOption": "导入列表",
|
||||
"SiteCount": "{n} 个网站",
|
||||
"Import": "导入",
|
||||
"RemoveList": "移除该列表",
|
||||
"Capacity": "已用 {used}/{limit}",
|
||||
"NotEnoughCapacity": "容量不足——该列表需要 {need} 个空位,仅剩 {free} 个"
|
||||
},
|
||||
"importLists": {
|
||||
"defaults": "系统预设",
|
||||
"iran": "伊朗常用",
|
||||
"russia": "俄罗斯常用",
|
||||
"china": "中国常用",
|
||||
|
||||
+8
-1
@@ -6,6 +6,7 @@ import i18n from './locales/i18n.js';
|
||||
import { createInitialAchievementsState } from './data/achievements.js';
|
||||
import { createInitialIpDBs, buildDbUrl, applyConfigAvailability, nearestEnabledId } from './data/ip-databases.js';
|
||||
import { createDefaultPreferences, PREFS_STORAGE_KEY } from './data/default-preferences.js';
|
||||
import { buildInitialTargets } from './utils/connectivity-import.js';
|
||||
import { createMountingStatus, createLoadingStatus, DEFAULT_SECTION } from './data/sections.js';
|
||||
import { fetchWithTimeout } from './utils/fetch-with-timeout.js';
|
||||
const { t } = i18n.global;
|
||||
@@ -211,7 +212,13 @@ export const useMainStore = defineStore('main', {
|
||||
const defaultPreferences = createDefaultPreferences();
|
||||
const storedPreferences = localStorage.getItem(PREFS_STORAGE_KEY);
|
||||
const currentPreferences = storedPreferences ? JSON.parse(storedPreferences) : {};
|
||||
this.setPreferences({ ...defaultPreferences, ...currentPreferences });
|
||||
const merged = { ...defaultPreferences, ...currentPreferences };
|
||||
// One-time build of the Connectivity target set: defaults + legacy
|
||||
// flat customs (the legacy key stays for rollback, never written).
|
||||
if (!merged.connectivityTargets) {
|
||||
merged.connectivityTargets = buildInitialTargets(merged.customConnectivityTargets);
|
||||
}
|
||||
this.setPreferences(merged);
|
||||
},
|
||||
// fetch configs from server
|
||||
fetchConfigs() {
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Pure logic for the Connectivity "import a curated list" flow: planning an
|
||||
// import (dedupe against existing targets + shared cap) and removing a
|
||||
// previously imported list.
|
||||
// Pure logic for the connectivityTargets preference: one-time build,
|
||||
// import planning (hostname dedupe + all-or-nothing cap), the dialog's
|
||||
// "fully present" check, and removal. Immutable — callers persist results.
|
||||
|
||||
import { CONNECTIVITY_TARGET_LIMIT, faviconPath } from '../data/connectivity-import-lists.js';
|
||||
import { CONNECTIVITY_TARGET_LIMIT, DEFAULT_LIST_MEMBERS, faviconPath } from '../data/connectivity-import-lists.js';
|
||||
|
||||
const hostnameOf = (url) => {
|
||||
try {
|
||||
@@ -12,40 +12,52 @@ const hostnameOf = (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Decide what an import would actually add. `existingUrls` covers built-in
|
||||
// targets plus every current custom/imported one; `currentCount` is the
|
||||
// stored customConnectivityTargets length (built-ins don't count against
|
||||
// the cap). Returns preference-shaped entries ready to persist.
|
||||
//
|
||||
// All-or-nothing: a partial import would still mark the whole list as
|
||||
// imported (any stored member carries listId), hiding the import action with
|
||||
// no path to add the omitted members later — so when the fresh members don't
|
||||
// all fit, nothing is added and `overflowCount` reports the shortfall.
|
||||
// `freshCount` / `capacity` ride along for the caller's error message.
|
||||
export const planImport = (list, { existingUrls = [], currentCount = 0, limit = CONNECTIVITY_TARGET_LIMIT } = {}) => {
|
||||
const existingHosts = new Set(existingUrls.map(hostnameOf).filter(Boolean));
|
||||
// Stored target shape: { id, name, url, favicon? }.
|
||||
const toStoredTarget = (m) => ({
|
||||
id: m.id,
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
...(m.favicon ? { favicon: m.favicon } : {}),
|
||||
});
|
||||
|
||||
// Defaults plus the legacy customConnectivityTargets entries (listId tags
|
||||
// dropped). No cap here — migration must never drop sites; an over-cap
|
||||
// set just can't grow.
|
||||
export const buildInitialTargets = (legacyTargets = []) => [
|
||||
...DEFAULT_LIST_MEMBERS.map((m) => toStoredTarget({ ...m, favicon: faviconPath(m.id) })),
|
||||
...legacyTargets.map(toStoredTarget),
|
||||
];
|
||||
|
||||
// What importing a curated list would add. Dedupe is by hostname, so lists
|
||||
// may overlap each other and the defaults. All-or-nothing: if the fresh
|
||||
// members don't all fit under the cap, nothing is added and overflowCount
|
||||
// reports the shortfall — a partial import would sit behind a full ✓.
|
||||
export const planImport = (list, targets = [], { limit = CONNECTIVITY_TARGET_LIMIT } = {}) => {
|
||||
const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean));
|
||||
const fresh = list.members.filter((m) => !existingHosts.has(hostnameOf(m.url)));
|
||||
const skippedCount = list.members.length - fresh.length;
|
||||
const capacity = Math.max(0, limit - currentCount);
|
||||
const capacity = Math.max(0, limit - targets.length);
|
||||
if (fresh.length > capacity) {
|
||||
return { additions: [], skippedCount, overflowCount: fresh.length - capacity, freshCount: fresh.length, capacity };
|
||||
}
|
||||
const additions = fresh.map((m) => ({
|
||||
const additions = fresh.map((m) => toStoredTarget({
|
||||
...m,
|
||||
id: `import-${m.id}`,
|
||||
name: m.name,
|
||||
url: m.url,
|
||||
listId: list.id,
|
||||
favicon: faviconPath(m.id),
|
||||
}));
|
||||
return { additions, skippedCount, overflowCount: 0, freshCount: fresh.length, capacity };
|
||||
};
|
||||
|
||||
// Ids of lists that currently have at least one imported member standing.
|
||||
export const importedListIds = (customTargets = []) => new Set(
|
||||
customTargets.map((t) => t.listId).filter(Boolean),
|
||||
);
|
||||
// True when every member of the curated list is already present (by
|
||||
// hostname) — the dialog shows a check instead of the import button then.
|
||||
export const isListFullyPresent = (list, targets = []) => {
|
||||
const existingHosts = new Set(targets.map((t) => hostnameOf(t.url)).filter(Boolean));
|
||||
return list.members.every((m) => existingHosts.has(hostnameOf(m.url)));
|
||||
};
|
||||
|
||||
// Drop every remaining member of one imported list, keep everything else.
|
||||
export const withoutList = (customTargets = [], listId) => customTargets.filter(
|
||||
(t) => t.listId !== listId,
|
||||
);
|
||||
// Remove one target; the set always keeps at least one site (the UI
|
||||
// toasts on the last one — this guard holds for any caller).
|
||||
export const removeTarget = (targets = [], id) => {
|
||||
if (targets.length <= 1) return { error: 'last-target' };
|
||||
return { targets: targets.filter((t) => t.id !== id) };
|
||||
};
|
||||
@@ -11,6 +11,8 @@ import { fileURLToPath } from 'node:url';
|
||||
import {
|
||||
IMPORT_LISTS,
|
||||
BUILTIN_FAVICONS,
|
||||
DEFAULT_LIST_MEMBERS,
|
||||
SYSTEM_IMPORT_LIST,
|
||||
TILE_PREVIEW,
|
||||
faviconPath,
|
||||
CONNECTIVITY_TARGET_LIMIT,
|
||||
@@ -41,9 +43,41 @@ describe('import lists data integrity', () => {
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
});
|
||||
|
||||
it('member hostnames are unique across all lists (import dedupe assumes it)', () => {
|
||||
const hosts = allMembers.map((m) => new URL(m.url).hostname);
|
||||
assert.equal(new Set(hosts).size, hosts.length);
|
||||
it('member hostnames are unique within each list (import dedupe is by hostname)', () => {
|
||||
// Cross-list overlap is deliberate — themed lists stay semantically
|
||||
// complete even where they intersect the defaults (AI ⊃ ChatGPT).
|
||||
for (const list of IMPORT_LISTS) {
|
||||
const hosts = list.members.map((m) => new URL(m.url).hostname);
|
||||
assert.equal(new Set(hosts).size, hosts.length, list.id);
|
||||
}
|
||||
});
|
||||
|
||||
it('members shared with the defaults reuse the exact default definition', () => {
|
||||
// Overlapping entries are references into DEFAULT_LIST_MEMBERS, so
|
||||
// url / iconDomain can never drift between the two.
|
||||
const defaults = new Map(DEFAULT_LIST_MEMBERS.map((m) => [m.id, m]));
|
||||
let shared = 0;
|
||||
for (const m of allMembers) {
|
||||
if (!defaults.has(m.id)) continue;
|
||||
assert.equal(m, defaults.get(m.id), m.id);
|
||||
shared += 1;
|
||||
}
|
||||
assert.ok(shared > 0, 'expected some default members inside themed lists');
|
||||
});
|
||||
|
||||
it('the system import list re-exposes the defaults under a non-clashing id', () => {
|
||||
assert.equal(SYSTEM_IMPORT_LIST.members, DEFAULT_LIST_MEMBERS);
|
||||
assert.ok(SYSTEM_IMPORT_LIST.emoji.length > 0);
|
||||
assert.ok(!IMPORT_LISTS.some((l) => l.id === SYSTEM_IMPORT_LIST.id));
|
||||
});
|
||||
|
||||
it('default list members are well-formed and favicon-backed', () => {
|
||||
const ids = DEFAULT_LIST_MEMBERS.map((m) => m.id);
|
||||
assert.equal(new Set(ids).size, ids.length);
|
||||
for (const m of DEFAULT_LIST_MEMBERS) {
|
||||
assert.equal(new URL(m.url).protocol, 'https:', m.id);
|
||||
assert.ok(existsSync(faviconFile(m.id)), `missing public/favicons/${m.id}.png`);
|
||||
}
|
||||
});
|
||||
|
||||
it('every member URL is HTTPS and parseable', () => {
|
||||
|
||||
@@ -1,15 +1,20 @@
|
||||
// Tests for the Connectivity import-list logic
|
||||
// (frontend/utils/connectivity-import.js): import planning (dedupe + cap),
|
||||
// list removal, and imported-list detection.
|
||||
// Tests for frontend/utils/connectivity-import.js: initial build, import
|
||||
// planning, the "fully present" check, and removal with its guard.
|
||||
|
||||
import assert from 'node:assert/strict';
|
||||
import { describe, it } from 'node:test';
|
||||
import {
|
||||
buildInitialTargets,
|
||||
planImport,
|
||||
importedListIds,
|
||||
withoutList,
|
||||
isListFullyPresent,
|
||||
removeTarget,
|
||||
} from '../frontend/utils/connectivity-import.js';
|
||||
import { CONNECTIVITY_TARGET_LIMIT } from '../frontend/data/connectivity-import-lists.js';
|
||||
import {
|
||||
CONNECTIVITY_TARGET_LIMIT,
|
||||
DEFAULT_LIST_MEMBERS,
|
||||
} from '../frontend/data/connectivity-import-lists.js';
|
||||
|
||||
const target = (id, host = `${id}.example`) => ({ id, name: id, url: `https://${host}/favicon.ico` });
|
||||
|
||||
const sampleList = {
|
||||
id: 'sample',
|
||||
@@ -20,9 +25,33 @@ const sampleList = {
|
||||
],
|
||||
};
|
||||
|
||||
describe('buildInitialTargets', () => {
|
||||
it('seeds the defaults plus legacy targets, dropping provenance tags', () => {
|
||||
const legacy = [
|
||||
{ id: 'custom-1', name: 'Probe', url: 'https://probe.example/health' },
|
||||
{ id: 'import-ozon', name: 'Ozon', url: 'https://www.ozon.ru/favicon.ico', listId: 'russia', favicon: '/favicons/ozon.png' },
|
||||
];
|
||||
const targets = buildInitialTargets(legacy);
|
||||
assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + 2);
|
||||
// Defaults carry their committed favicon.
|
||||
const github = targets.find((m) => m.id === 'github');
|
||||
assert.equal(github.favicon, '/favicons/github.png');
|
||||
// Legacy entries keep their favicon but lose the listId tag.
|
||||
const ozon = targets.find((m) => m.id === 'import-ozon');
|
||||
assert.equal(ozon.favicon, '/favicons/ozon.png');
|
||||
assert.equal('listId' in ozon, false);
|
||||
});
|
||||
|
||||
it('never drops legacy sites, even past the cap', () => {
|
||||
const legacy = Array.from({ length: CONNECTIVITY_TARGET_LIMIT }, (_, i) => target(`custom-${i}`));
|
||||
const targets = buildInitialTargets(legacy);
|
||||
assert.equal(targets.length, DEFAULT_LIST_MEMBERS.length + CONNECTIVITY_TARGET_LIMIT);
|
||||
});
|
||||
});
|
||||
|
||||
describe('planImport', () => {
|
||||
it('materializes members as preference entries tagged with the list id', () => {
|
||||
const { additions, skippedCount, overflowCount } = planImport(sampleList, {});
|
||||
it('materializes members with import ids and favicons', () => {
|
||||
const { additions, skippedCount, overflowCount } = planImport(sampleList, []);
|
||||
assert.equal(additions.length, 3);
|
||||
assert.equal(skippedCount, 0);
|
||||
assert.equal(overflowCount, 0);
|
||||
@@ -30,26 +59,22 @@ describe('planImport', () => {
|
||||
id: 'import-alpha',
|
||||
name: 'Alpha',
|
||||
url: 'https://alpha.example/favicon.ico',
|
||||
listId: 'sample',
|
||||
favicon: '/favicons/alpha.png',
|
||||
});
|
||||
});
|
||||
|
||||
it('skips members whose hostname already exists (built-in or custom)', () => {
|
||||
const { additions, skippedCount } = planImport(sampleList, {
|
||||
existingUrls: [
|
||||
'https://www.beta.example/some/other/path.ico',
|
||||
'https://unrelated.example/favicon.ico',
|
||||
],
|
||||
});
|
||||
it('skips members whose hostname is already present', () => {
|
||||
const { additions, skippedCount } = planImport(sampleList, [
|
||||
target('x', 'www.beta.example'),
|
||||
target('y', 'unrelated.example'),
|
||||
]);
|
||||
assert.deepEqual(additions.map((a) => a.id), ['import-alpha', 'import-gamma']);
|
||||
assert.equal(skippedCount, 1);
|
||||
});
|
||||
|
||||
it('refuses a partial import when the list does not fully fit (all-or-nothing)', () => {
|
||||
const { additions, overflowCount, freshCount, capacity } = planImport(sampleList, {
|
||||
currentCount: CONNECTIVITY_TARGET_LIMIT - 1,
|
||||
});
|
||||
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 1 }, (_, i) => target(`m${i}`));
|
||||
const { additions, overflowCount, freshCount, capacity } = planImport(sampleList, existing);
|
||||
assert.equal(additions.length, 0);
|
||||
assert.equal(overflowCount, 2);
|
||||
assert.equal(freshCount, 3);
|
||||
@@ -57,52 +82,39 @@ describe('planImport', () => {
|
||||
});
|
||||
|
||||
it('imports in full when the list exactly fits the remaining capacity', () => {
|
||||
const { additions, overflowCount } = planImport(sampleList, {
|
||||
currentCount: CONNECTIVITY_TARGET_LIMIT - 3,
|
||||
});
|
||||
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT - 3 }, (_, i) => target(`m${i}`));
|
||||
const { additions, overflowCount } = planImport(sampleList, existing);
|
||||
assert.equal(additions.length, 3);
|
||||
assert.equal(overflowCount, 0);
|
||||
});
|
||||
|
||||
it('adds nothing when the cap is already reached', () => {
|
||||
const { additions, overflowCount } = planImport(sampleList, {
|
||||
currentCount: CONNECTIVITY_TARGET_LIMIT,
|
||||
});
|
||||
it('an over-cap set (legacy migration) can only shed, never gain', () => {
|
||||
const existing = Array.from({ length: CONNECTIVITY_TARGET_LIMIT + 5 }, (_, i) => target(`m${i}`));
|
||||
const { additions, overflowCount, capacity } = planImport(sampleList, existing);
|
||||
assert.equal(additions.length, 0);
|
||||
assert.equal(capacity, 0);
|
||||
assert.equal(overflowCount, 3);
|
||||
});
|
||||
});
|
||||
|
||||
it('counts only fresh members against capacity — deduped ones do not block', () => {
|
||||
// 1 slot left, 2 of 3 members already present: the single fresh one fits.
|
||||
const { additions, overflowCount, skippedCount } = planImport(sampleList, {
|
||||
existingUrls: ['https://alpha.example/x', 'https://www.beta.example/x'],
|
||||
currentCount: CONNECTIVITY_TARGET_LIMIT - 1,
|
||||
});
|
||||
assert.deepEqual(additions.map((a) => a.id), ['import-gamma']);
|
||||
assert.equal(overflowCount, 0);
|
||||
assert.equal(skippedCount, 2);
|
||||
describe('isListFullyPresent', () => {
|
||||
it('is true when every member hostname exists in the target set', () => {
|
||||
const targets = [target('x', 'alpha.example'), target('y', 'www.beta.example'), target('z', 'gamma.example')];
|
||||
assert.equal(isListFullyPresent(sampleList, targets), true);
|
||||
});
|
||||
|
||||
it('ignores malformed existing URLs instead of throwing', () => {
|
||||
const { additions } = planImport(sampleList, { existingUrls: ['not a url'] });
|
||||
assert.equal(additions.length, 3);
|
||||
it('is false while any member is missing', () => {
|
||||
assert.equal(isListFullyPresent(sampleList, [target('x', 'alpha.example')]), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('importedListIds / withoutList', () => {
|
||||
const prefs = [
|
||||
{ id: 'custom-1', name: 'Mine', url: 'https://mine.example/favicon.ico' },
|
||||
{ id: 'import-alpha', listId: 'sample', url: 'https://alpha.example/favicon.ico' },
|
||||
{ id: 'import-yandex', listId: 'russia', url: 'https://yandex.ru/favicon.ico' },
|
||||
];
|
||||
|
||||
it('collects listIds from imported entries only', () => {
|
||||
assert.deepEqual([...importedListIds(prefs)].sort(), ['russia', 'sample']);
|
||||
describe('removeTarget', () => {
|
||||
it('removes the matching target', () => {
|
||||
const { targets } = removeTarget([target('a'), target('b')], 'a');
|
||||
assert.deepEqual(targets.map((m) => m.id), ['b']);
|
||||
});
|
||||
|
||||
it('removes every member of one list, keeps customs and other lists', () => {
|
||||
const left = withoutList(prefs, 'sample');
|
||||
assert.deepEqual(left.map((t) => t.id), ['custom-1', 'import-yandex']);
|
||||
it('refuses to remove the last remaining target', () => {
|
||||
assert.equal(removeTarget([target('a')], 'a').error, 'last-target');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,6 +25,7 @@ describe('DEFAULT_PREFERENCES', () => {
|
||||
ipHistoryEnabled: true,
|
||||
ipHistoryDays: 90,
|
||||
lang: 'auto',
|
||||
connectivityTargets: null,
|
||||
customConnectivityTargets: [],
|
||||
});
|
||||
});
|
||||
|
||||
Reference in new issue
Block a user