feat: add duplicate detection modes and UI enhancements for managing duplicates

This commit is contained in:
shuaiplus
2026-06-15 20:48:57 +08:00
parent a8183166ac
commit 7b3be2c819
11 changed files with 279 additions and 44 deletions
+48 -9
View File
@@ -17,13 +17,14 @@ import {
createEmptyDraft, createEmptyDraft,
creationTimeValue, creationTimeValue,
draftFromCipher, draftFromCipher,
buildCipherDuplicateSignature, buildCipherDuplicateSignatures,
firstCipherUri, firstCipherUri,
firstPasskeyCreationTime, firstPasskeyCreationTime,
isCipherVisibleInArchive, isCipherVisibleInArchive,
isCipherVisibleInNormalVault, isCipherVisibleInNormalVault,
isCipherVisibleInTrash, isCipherVisibleInTrash,
sortTimeValue, sortTimeValue,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -79,6 +80,7 @@ export default function VaultPage(props: VaultPageProps) {
const [sortMenuOpen, setSortMenuOpen] = useState(false); const [sortMenuOpen, setSortMenuOpen] = useState(false);
const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name'); const [folderSortMode, setFolderSortMode] = useState<VaultSortMode>('name');
const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false); const [folderSortMenuOpen, setFolderSortMenuOpen] = useState(false);
const [duplicateMode, setDuplicateMode] = useState<DuplicateDetectionMode>('exact');
const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' }); const [sidebarFilter, setSidebarFilter] = useState<SidebarFilter>({ kind: 'all' });
const [selectedCipherId, setSelectedCipherId] = useState(''); const [selectedCipherId, setSelectedCipherId] = useState('');
const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({}); const [selectedMap, setSelectedMap] = useState<Record<string, boolean>>({});
@@ -338,16 +340,41 @@ export default function VaultPage(props: VaultPageProps) {
const duplicateSignatureInfo = useMemo(() => { const duplicateSignatureInfo = useMemo(() => {
if (sidebarFilter.kind !== 'duplicates') return null; if (sidebarFilter.kind !== 'duplicates') return null;
const byId = new Map<string, string>(); const byId = new Map<string, string[]>();
const counts = new Map<string, number>(); const counts = new Map<string, number>();
for (const cipher of props.ciphers) { for (const cipher of props.ciphers) {
if (!isCipherVisibleInNormalVault(cipher)) continue; if (!isCipherVisibleInNormalVault(cipher)) continue;
const signature = buildCipherDuplicateSignature(cipher); const signatures = Array.from(new Set(buildCipherDuplicateSignatures(cipher, duplicateMode)));
byId.set(cipher.id, signature); byId.set(cipher.id, signatures);
for (const signature of signatures) {
counts.set(signature, (counts.get(signature) || 0) + 1); counts.set(signature, (counts.get(signature) || 0) + 1);
} }
}
return { byId, counts }; return { byId, counts };
}, [props.ciphers, sidebarFilter.kind]); }, [props.ciphers, sidebarFilter.kind, duplicateMode]);
const duplicateGroupIndexById = useMemo(() => {
if (!duplicateSignatureInfo) return new Map<string, number>();
const groupKeyById = new Map<string, string>();
const groupKeys = new Set<string>();
for (const cipher of props.ciphers) {
const groupKey = (duplicateSignatureInfo.byId.get(cipher.id) || [])
.filter((signature) => (duplicateSignatureInfo.counts.get(signature) || 0) >= 2)
.sort()[0];
if (!groupKey) continue;
groupKeyById.set(cipher.id, groupKey);
groupKeys.add(groupKey);
}
const groupIndexByKey = new Map<string, number>();
Array.from(groupKeys).sort().forEach((groupKey, index) => {
groupIndexByKey.set(groupKey, index % 64);
});
const byId = new Map<string, number>();
for (const [cipherId, groupKey] of groupKeyById.entries()) {
byId.set(cipherId, groupIndexByKey.get(groupKey) || 0);
}
return byId;
}, [props.ciphers, duplicateSignatureInfo]);
const filteredCiphers = useMemo(() => { const filteredCiphers = useMemo(() => {
const next = props.ciphers.filter((cipher) => { const next = props.ciphers.filter((cipher) => {
@@ -358,9 +385,12 @@ export default function VaultPage(props: VaultPageProps) {
if (!isCipherVisibleInArchive(cipher)) return false; if (!isCipherVisibleInArchive(cipher)) return false;
} else { } else {
if (!isCipherVisibleInNormalVault(cipher)) return false; if (!isCipherVisibleInNormalVault(cipher)) return false;
if (sidebarFilter.kind === 'duplicates' && ((duplicateSignatureInfo?.counts.get(duplicateSignatureInfo.byId.get(cipher.id) || '') || 0) < 2)) { if (sidebarFilter.kind === 'duplicates') {
const signatures = duplicateSignatureInfo?.byId.get(cipher.id) || [];
if (!signatures.some((signature) => (duplicateSignatureInfo?.counts.get(signature) || 0) >= 2)) {
return false; return false;
} }
}
if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false; if (sidebarFilter.kind === 'favorite' && !cipher.favorite) return false;
if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false; if (sidebarFilter.kind === 'type' && meta?.typeKey !== sidebarFilter.value) return false;
if (sidebarFilter.kind === 'folder') { if (sidebarFilter.kind === 'folder') {
@@ -404,8 +434,9 @@ export default function VaultPage(props: VaultPageProps) {
const sidebarFilterKey = useMemo(() => { const sidebarFilterKey = useMemo(() => {
if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`; if (sidebarFilter.kind === 'folder') return `folder:${sidebarFilter.folderId ?? 'none'}`;
if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`; if (sidebarFilter.kind === 'type') return `type:${sidebarFilter.value}`;
if (sidebarFilter.kind === 'duplicates') return `duplicates:${duplicateMode}`;
return sidebarFilter.kind; return sidebarFilter.kind;
}, [sidebarFilter]); }, [sidebarFilter, duplicateMode]);
useEffect(() => { useEffect(() => {
setListScrollTop(0); setListScrollTop(0);
@@ -419,6 +450,10 @@ export default function VaultPage(props: VaultPageProps) {
} }
}, [sidebarFilter.kind, sortMode]); }, [sidebarFilter.kind, sortMode]);
useEffect(() => {
if (sidebarFilter.kind === 'duplicates') setSelectedMap({});
}, [sidebarFilter.kind, duplicateMode]);
useEffect(() => { useEffect(() => {
if (isCreating) return; if (isCreating) return;
if (!filteredCiphers.length) { if (!filteredCiphers.length) {
@@ -984,10 +1019,11 @@ const folderName = useCallback((id: string | null | undefined): string => {
const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]); const handleSyncVault = useCallback(() => { void syncVault(); }, [props.onRefresh]);
const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []); const handleOpenBulkDelete = useCallback(() => setBulkDeleteOpen(true), []);
const handleSelectDuplicates = useCallback(() => { const handleSelectDuplicates = useCallback(() => {
if (duplicateMode !== 'exact') return;
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
const seen = new Set<string>(); const seen = new Set<string>();
for (const cipher of filteredCiphers) { for (const cipher of filteredCiphers) {
const signature = duplicateSignatureInfo?.byId.get(cipher.id) || buildCipherDuplicateSignature(cipher); const signature = duplicateSignatureInfo?.byId.get(cipher.id)?.[0] || buildCipherDuplicateSignatures(cipher, 'exact')[0];
if (seen.has(signature)) { if (seen.has(signature)) {
map[cipher.id] = true; map[cipher.id] = true;
continue; continue;
@@ -995,7 +1031,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
seen.add(signature); seen.add(signature);
} }
setSelectedMap(map); setSelectedMap(map);
}, [filteredCiphers, duplicateSignatureInfo]); }, [filteredCiphers, duplicateSignatureInfo, duplicateMode]);
const handleSelectAll = useCallback(() => { const handleSelectAll = useCallback(() => {
const map: Record<string, boolean> = {}; const map: Record<string, boolean> = {};
for (const cipher of filteredCiphers) map[cipher.id] = true; for (const cipher of filteredCiphers) map[cipher.id] = true;
@@ -1082,10 +1118,12 @@ const folderName = useCallback((id: string | null | undefined): string => {
searchInput={searchInput} searchInput={searchInput}
sortMode={sortMode} sortMode={sortMode}
sortMenuOpen={sortMenuOpen} sortMenuOpen={sortMenuOpen}
duplicateMode={duplicateMode}
selectedCount={selectedCount} selectedCount={selectedCount}
totalCipherCount={totalCipherCount} totalCipherCount={totalCipherCount}
filteredCiphers={filteredCiphers} filteredCiphers={filteredCiphers}
visibleCiphers={visibleCiphers} visibleCiphers={visibleCiphers}
duplicateGroupIndexById={duplicateGroupIndexById}
virtualRange={virtualRange} virtualRange={virtualRange}
selectedCipherId={selectedCipherId} selectedCipherId={selectedCipherId}
selectedMap={selectedMap} selectedMap={selectedMap}
@@ -1102,6 +1140,7 @@ const folderName = useCallback((id: string | null | undefined): string => {
onSearchCompositionEnd={handleSearchCompositionEnd} onSearchCompositionEnd={handleSearchCompositionEnd}
onToggleSortMenu={handleToggleSortMenu} onToggleSortMenu={handleToggleSortMenu}
onSelectSortMode={handleSelectSortMode} onSelectSortMode={handleSelectSortMode}
onDuplicateModeChange={setDuplicateMode}
onSyncVault={handleSyncVault} onSyncVault={handleSyncVault}
onOpenBulkDelete={handleOpenBulkDelete} onOpenBulkDelete={handleOpenBulkDelete}
onSelectDuplicates={handleSelectDuplicates} onSelectDuplicates={handleSelectDuplicates}
+46 -5
View File
@@ -8,8 +8,10 @@ import { t } from '@/lib/i18n';
import { import {
CreateTypeIcon, CreateTypeIcon,
getCreateTypeOptions, getCreateTypeOptions,
getDuplicateDetectionOptions,
getVaultSortOptions, getVaultSortOptions,
VaultListIcon, VaultListIcon,
type DuplicateDetectionMode,
type SidebarFilter, type SidebarFilter,
type VaultSortMode, type VaultSortMode,
} from '@/components/vault/vault-page-helpers'; } from '@/components/vault/vault-page-helpers';
@@ -28,10 +30,12 @@ interface VaultListPanelProps {
searchInput: string; searchInput: string;
sortMode: VaultSortMode; sortMode: VaultSortMode;
sortMenuOpen: boolean; sortMenuOpen: boolean;
duplicateMode: DuplicateDetectionMode;
selectedCount: number; selectedCount: number;
totalCipherCount: number; totalCipherCount: number;
filteredCiphers: Cipher[]; filteredCiphers: Cipher[];
visibleCiphers: Cipher[]; visibleCiphers: Cipher[];
duplicateGroupIndexById: Map<string, number>;
virtualRange: VirtualRange; virtualRange: VirtualRange;
selectedCipherId: string; selectedCipherId: string;
selectedMap: Record<string, boolean>; selectedMap: Record<string, boolean>;
@@ -48,6 +52,7 @@ interface VaultListPanelProps {
onSearchCompositionEnd: (value: string) => void; onSearchCompositionEnd: (value: string) => void;
onToggleSortMenu: () => void; onToggleSortMenu: () => void;
onSelectSortMode: (value: VaultSortMode) => void; onSelectSortMode: (value: VaultSortMode) => void;
onDuplicateModeChange: (value: DuplicateDetectionMode) => void;
onSyncVault: () => void; onSyncVault: () => void;
onOpenBulkDelete: () => void; onOpenBulkDelete: () => void;
onSelectDuplicates: () => void; onSelectDuplicates: () => void;
@@ -69,15 +74,18 @@ interface CipherListItemProps {
cipher: Cipher; cipher: Cipher;
selected: boolean; selected: boolean;
checked: boolean; checked: boolean;
duplicateGroupIndex: number | null;
subtitle: string; subtitle: string;
onToggleSelected: (cipherId: string, checked: boolean) => void; onToggleSelected: (cipherId: string, checked: boolean) => void;
onSelectCipher: (cipherId: string) => void; onSelectCipher: (cipherId: string) => void;
} }
const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) { const CipherListItem = memo(function CipherListItem(props: CipherListItemProps) {
const duplicateGroupHue = props.duplicateGroupIndex === null ? null : (props.duplicateGroupIndex * 137.508) % 360;
return ( return (
<div <div
className={`list-item ${props.selected ? 'active' : ''}`} className={`list-item ${props.selected ? 'active' : ''} ${duplicateGroupHue === null ? '' : 'duplicate-group-item'}`}
style={duplicateGroupHue === null ? undefined : { '--duplicate-group-hue': `${duplicateGroupHue}deg` }}
onClick={(event) => { onClick={(event) => {
const target = event.target as HTMLElement; const target = event.target as HTMLElement;
if (target.closest('.row-check')) return; if (target.closest('.row-check')) return;
@@ -108,6 +116,7 @@ const CipherListItem = memo(function CipherListItem(props: CipherListItemProps)
export default function VaultListPanel(props: VaultListPanelProps) { export default function VaultListPanel(props: VaultListPanelProps) {
const createTypeOptions = getCreateTypeOptions(); const createTypeOptions = getCreateTypeOptions();
const duplicateDetectionOptions = getDuplicateDetectionOptions();
const vaultSortOptions = getVaultSortOptions(); const vaultSortOptions = getVaultSortOptions();
const createMenu = ( const createMenu = (
<div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}> <div className="create-menu-wrap mobile-fab-wrap" ref={props.createMenuRef}>
@@ -137,6 +146,19 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<section className="list-col"> <section className="list-col">
<div className="list-head"> <div className="list-head">
<div className="search-input-wrap"> <div className="search-input-wrap">
{props.sidebarFilter.kind === 'duplicates' && props.isMobileLayout ? (
<select
className="input duplicate-mode-select duplicate-mode-head-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
) : (
<>
<input <input
className="search-input" className="search-input"
placeholder={t('txt_search_your_secure_vault')} placeholder={t('txt_search_your_secure_vault')}
@@ -161,6 +183,8 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<X size={14} /> <X size={14} />
</button> </button>
)} )}
</>
)}
</div> </div>
<div className="sort-menu-wrap" ref={props.sortMenuRef}> <div className="sort-menu-wrap" ref={props.sortMenuRef}>
<button <button
@@ -195,8 +219,20 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')} <RefreshCw size={14} className="btn-icon" /> {t('txt_sync_vault')}
</button> </button>
</div> </div>
<div className="toolbar actions"> <div className={`toolbar actions ${props.sidebarFilter.kind === 'duplicates' ? 'duplicates-toolbar' : ''}`}>
{props.sidebarFilter.kind === 'duplicates' && ( {props.sidebarFilter.kind === 'duplicates' && !props.isMobileLayout && (
<select
className="input duplicate-mode-select duplicate-mode-toolbar-select"
value={props.duplicateMode}
aria-label={t('txt_duplicate_detection_mode')}
onChange={(event) => props.onDuplicateModeChange((event.currentTarget as HTMLSelectElement).value as DuplicateDetectionMode)}
>
{duplicateDetectionOptions.map((option) => (
<option key={option.value} value={option.value}>{option.label}</option>
))}
</select>
)}
{props.sidebarFilter.kind === 'duplicates' && props.duplicateMode === 'exact' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}> <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length || props.busy} onClick={props.onSelectDuplicates}>
<Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')} <Check size={14} className="btn-icon" /> {t('txt_select_duplicate_items')}
</button> </button>
@@ -229,12 +265,16 @@ export default function VaultListPanel(props: VaultListPanelProps) {
<button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}> <button type="button" className="btn btn-danger small" disabled={!props.selectedCount || props.busy} onClick={props.onOpenBulkDelete}>
<Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')} <Trash2 size={14} className="btn-icon" /> {props.sidebarFilter.kind === 'trash' ? t('txt_delete_permanently') : t('txt_delete_selected')}
</button> </button>
{props.sidebarFilter.kind !== 'duplicates' && (
<button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}> <button type="button" className="btn btn-secondary small" disabled={!props.filteredCiphers.length} onClick={props.onSelectAll}>
<CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')} <CheckCheck size={14} className="btn-icon" /> {t('txt_select_all')}
</button> </button>
{props.isMobileLayout && typeof document !== 'undefined' )}
{props.sidebarFilter.kind !== 'duplicates' && (
props.isMobileLayout && typeof document !== 'undefined'
? props.mobileFabVisible ? createPortal(createMenu, document.body) : null ? props.mobileFabVisible ? createPortal(createMenu, document.body) : null
: createMenu} : createMenu
)}
</div> </div>
<div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}> <div className="list-panel" ref={props.listPanelRef} onScroll={(event) => props.onScroll((event.currentTarget as HTMLDivElement).scrollTop)}>
@@ -255,6 +295,7 @@ export default function VaultListPanel(props: VaultListPanelProps) {
cipher={cipher} cipher={cipher}
selected={props.selectedCipherId === cipher.id} selected={props.selectedCipherId === cipher.id}
checked={!!props.selectedMap[cipher.id]} checked={!!props.selectedMap[cipher.id]}
duplicateGroupIndex={props.sidebarFilter.kind === 'duplicates' ? props.duplicateGroupIndexById.get(cipher.id) ?? null : null}
subtitle={props.listSubtitle(cipher)} subtitle={props.listSubtitle(cipher)}
onToggleSelected={props.onToggleSelected} onToggleSelected={props.onToggleSelected}
onSelectCipher={props.onSelectCipher} onSelectCipher={props.onSelectCipher}
@@ -10,10 +10,13 @@ import {
import { copyTextToClipboard } from '@/lib/clipboard'; import { copyTextToClipboard } from '@/lib/clipboard';
import { t } from '@/lib/i18n'; import { t } from '@/lib/i18n';
import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types'; import type { Cipher, CipherAttachment, CustomFieldType, VaultDraft, VaultDraftField, VaultDraftLoginUri } from '@/lib/types';
import { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils';
import { normalizeEquivalentDomain } from '@shared/domain-normalize';
import WebsiteIcon from './WebsiteIcon'; import WebsiteIcon from './WebsiteIcon';
export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh'; export type TypeFilter = 'login' | 'card' | 'identity' | 'note' | 'ssh';
export type VaultSortMode = 'edited' | 'created' | 'name'; export type VaultSortMode = 'edited' | 'created' | 'name';
export type DuplicateDetectionMode = 'exact' | 'login-site' | 'login-credentials' | 'password';
export type SidebarFilter = export type SidebarFilter =
| { kind: 'all' } | { kind: 'all' }
| { kind: 'favorite' } | { kind: 'favorite' }
@@ -126,6 +129,16 @@ export const FOLDER_SORT_STORAGE_KEY = 'nodewarden.folder-sort.v1';
export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)'; export const MOBILE_LAYOUT_QUERY = '(max-width: 1180px)';
export const VAULT_LIST_ROW_HEIGHT = 74; export const VAULT_LIST_ROW_HEIGHT = 74;
export const VAULT_LIST_OVERSCAN = 10; export const VAULT_LIST_OVERSCAN = 10;
export function getDuplicateDetectionOptions(): Array<{ value: DuplicateDetectionMode; label: string }> {
return [
{ value: 'exact', label: t('txt_duplicate_mode_exact') },
{ value: 'login-site', label: t('txt_duplicate_mode_login_site') },
{ value: 'login-credentials', label: t('txt_duplicate_mode_login_credentials') },
{ value: 'password', label: t('txt_duplicate_mode_password') },
];
}
export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> { export function getVaultSortOptions(): Array<{ value: VaultSortMode; label: string }> {
return [ return [
{ value: 'edited', label: t('txt_sort_last_edited') }, { value: 'edited', label: t('txt_sort_last_edited') },
@@ -242,7 +255,7 @@ export function toBooleanFieldValue(raw: string): boolean {
return v === '1' || v === 'true' || v === 'yes' || v === 'on'; return v === '1' || v === 'true' || v === 'yes' || v === 'on';
} }
export { firstCipherUri, hostFromUri, websiteIconUrl } from '@/lib/website-utils'; export { firstCipherUri, hostFromUri, websiteIconUrl };
export function createEmptyLoginUri(): VaultDraftLoginUri { export function createEmptyLoginUri(): VaultDraftLoginUri {
return { uri: '', match: null, originalUri: '', extra: {} }; return { uri: '', match: null, originalUri: '', extra: {} };
@@ -257,6 +270,30 @@ function valueOrFallback(value: string | null | undefined): string {
return String(value || ''); return String(value || '');
} }
function duplicateLoginUsername(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decUsername ?? cipher.login?.username).trim().toLowerCase();
}
function duplicateLoginPassword(cipher: Cipher): string {
return valueOrFallback(cipher.login?.decPassword ?? cipher.login?.password);
}
function duplicateLoginSites(cipher: Cipher): string[] {
const sites = new Set<string>();
for (const uri of cipher.login?.uris || []) {
const raw = valueOrFallback(uri.decUri ?? uri.uri).trim();
if (!raw) continue;
const host = hostFromUri(raw).trim().toLowerCase().replace(/^www\./, '');
const site = normalizeEquivalentDomain(raw) || host;
if (site) sites.add(site);
}
return Array.from(sites).sort();
}
function duplicateSignature(parts: string[]): string {
return JSON.stringify(parts);
}
export function buildCipherDuplicateSignature(cipher: Cipher): string { export function buildCipherDuplicateSignature(cipher: Cipher): string {
const normalized = { const normalized = {
type: Number(cipher.type || 1), type: Number(cipher.type || 1),
@@ -333,6 +370,23 @@ export function buildCipherDuplicateSignature(cipher: Cipher): string {
return JSON.stringify(normalized); return JSON.stringify(normalized);
} }
export function buildCipherDuplicateSignatures(cipher: Cipher, mode: DuplicateDetectionMode): string[] {
if (mode === 'exact') return [buildCipherDuplicateSignature(cipher)];
if (Number(cipher.type || 1) !== 1 || !cipher.login) return [];
const username = duplicateLoginUsername(cipher);
const password = duplicateLoginPassword(cipher);
if (mode === 'password') {
return password ? [duplicateSignature(['password', password])] : [];
}
if (!username || !password) return [];
if (mode === 'login-credentials') {
return [duplicateSignature(['login-credentials', username, password])];
}
return duplicateLoginSites(cipher).map((site) => duplicateSignature(['login-site', site, username, password]));
}
export function createEmptyDraft(type: number): VaultDraft { export function createEmptyDraft(type: number): VaultDraft {
return { return {
type, type,
+5
View File
@@ -450,6 +450,11 @@ const en: Record<string, string> = {
"txt_favorite": "Favorite", "txt_favorite": "Favorite",
"txt_favorites": "Favorites", "txt_favorites": "Favorites",
"txt_duplicates": "Duplicates", "txt_duplicates": "Duplicates",
"txt_duplicate_detection_mode": "Match by",
"txt_duplicate_mode_exact": "Exact item",
"txt_duplicate_mode_login_site": "Site + username + password",
"txt_duplicate_mode_login_credentials": "Username + password",
"txt_duplicate_mode_password": "Reused password",
"txt_field": "Field", "txt_field": "Field",
"txt_field_label": "Field Label", "txt_field_label": "Field Label",
"txt_field_label_is_required": "Field label is required.", "txt_field_label_is_required": "Field label is required.",
+5
View File
@@ -450,6 +450,11 @@ const es: Record<string, string> = {
"txt_favorite": "Favorito", "txt_favorite": "Favorito",
"txt_favorites": "Favoritos", "txt_favorites": "Favoritos",
"txt_duplicates": "Duplicados", "txt_duplicates": "Duplicados",
"txt_duplicate_detection_mode": "Coincidir por",
"txt_duplicate_mode_exact": "Elemento exacto",
"txt_duplicate_mode_login_site": "Sitio + usuario + contraseña",
"txt_duplicate_mode_login_credentials": "Usuario + contraseña",
"txt_duplicate_mode_password": "Contraseña reutilizada",
"txt_field": "Campo", "txt_field": "Campo",
"txt_field_label": "Etiqueta del campo", "txt_field_label": "Etiqueta del campo",
"txt_field_label_is_required": "La etiqueta del campo es obligatoria.", "txt_field_label_is_required": "La etiqueta del campo es obligatoria.",
+5
View File
@@ -450,6 +450,11 @@ const ru: Record<string, string> = {
"txt_favorite": "Любимый", "txt_favorite": "Любимый",
"txt_favorites": "Избранное", "txt_favorites": "Избранное",
"txt_duplicates": "Дубликаты", "txt_duplicates": "Дубликаты",
"txt_duplicate_detection_mode": "Сравнивать по",
"txt_duplicate_mode_exact": "Полное совпадение",
"txt_duplicate_mode_login_site": "Сайт + логин + пароль",
"txt_duplicate_mode_login_credentials": "Логин + пароль",
"txt_duplicate_mode_password": "Повтор пароля",
"txt_field": "Поле", "txt_field": "Поле",
"txt_field_label": "Метка поля", "txt_field_label": "Метка поля",
"txt_field_label_is_required": "Метка поля обязательна.", "txt_field_label_is_required": "Метка поля обязательна.",
+5
View File
@@ -450,6 +450,11 @@ const zhCN: Record<string, string> = {
"txt_favorite": "收藏", "txt_favorite": "收藏",
"txt_favorites": "收藏", "txt_favorites": "收藏",
"txt_duplicates": "重复项", "txt_duplicates": "重复项",
"txt_duplicate_detection_mode": "匹配方式",
"txt_duplicate_mode_exact": "完全相同",
"txt_duplicate_mode_login_site": "网站+账号+密码",
"txt_duplicate_mode_login_credentials": "账号+密码",
"txt_duplicate_mode_password": "密码复用",
"txt_field": "字段", "txt_field": "字段",
"txt_field_label": "字段标签", "txt_field_label": "字段标签",
"txt_field_label_is_required": "字段标签不能为空", "txt_field_label_is_required": "字段标签不能为空",
+5
View File
@@ -450,6 +450,11 @@ const zhTW: Record<string, string> = {
"txt_favorite": "收藏", "txt_favorite": "收藏",
"txt_favorites": "收藏", "txt_favorites": "收藏",
"txt_duplicates": "重複項", "txt_duplicates": "重複項",
"txt_duplicate_detection_mode": "匹配方式",
"txt_duplicate_mode_exact": "完全相同",
"txt_duplicate_mode_login_site": "網站+帳號+密碼",
"txt_duplicate_mode_login_credentials": "帳號+密碼",
"txt_duplicate_mode_password": "密碼重複使用",
"txt_field": "字段", "txt_field": "字段",
"txt_field_label": "字段標籤", "txt_field_label": "字段標籤",
"txt_field_label_is_required": "字段標籤不能為空", "txt_field_label_is_required": "字段標籤不能為空",
+15
View File
@@ -200,6 +200,21 @@
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12); box-shadow: 0 10px 28px rgba(0, 0, 0, 0.24), 0 0 0 1px rgba(139, 184, 255, 0.12);
} }
:root[data-theme='dark'] .list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 34% 18%);
border-color: hsl(var(--duplicate-group-hue) 28% 30%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 36% 21%);
border-color: hsl(var(--duplicate-group-hue) 34% 38%);
}
:root[data-theme='dark'] .list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 38% 24%);
border-color: hsl(var(--duplicate-group-hue) 42% 48%);
}
:root[data-theme='dark'] .card-brand-icon { :root[data-theme='dark'] .card-brand-icon {
color: #bfdbfe; color: #bfdbfe;
background: linear-gradient(180deg, #1f2937 0%, #111827 100%); background: linear-gradient(180deg, #1f2937 0%, #111827 100%);
+9
View File
@@ -319,6 +319,10 @@
@apply h-[42px] w-full min-w-0 rounded-[14px]; @apply h-[42px] w-full min-w-0 rounded-[14px];
} }
.list-head .duplicate-mode-head-select {
@apply h-[34px] min-w-0 w-auto max-w-full rounded-full;
}
.list-icon-btn { .list-icon-btn {
@apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px]; @apply w-auto min-w-0 gap-1.5 whitespace-nowrap px-3 py-0 text-[13px];
} }
@@ -329,6 +333,11 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply justify-start;
flex-wrap: wrap;
}
.actions { .actions {
gap: var(--actions-gap); gap: var(--actions-gap);
} }
+53 -1
View File
@@ -184,8 +184,45 @@
gap: var(--actions-gap); gap: var(--actions-gap);
} }
.toolbar.actions.duplicates-toolbar {
@apply items-center;
}
.toolbar .btn.small { .toolbar .btn.small {
@apply h-[30px] rounded-full text-xs; @apply h-[32px] rounded-full text-xs;
}
.duplicate-mode-select {
@apply h-8 min-w-[150px] rounded-full py-0 pl-3 pr-6 text-xs;
border-color: rgba(74, 103, 150, 0.26);
box-shadow: none;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
select.input.duplicate-mode-toolbar-select {
height: 32px;
padding-top: 0;
padding-right: 24px;
padding-bottom: 0;
line-height: 32px;
background-position:
calc(100% - 10px) calc(50% - 2px),
calc(100% - 6px) calc(50% - 2px);
background-size: 5px 5px, 5px 5px;
background-repeat: no-repeat;
}
.duplicate-mode-head-select {
@apply h-[34px] w-auto min-w-[156px] max-w-full;
}
.duplicate-mode-toolbar-select {
@apply w-auto max-w-[170px] shrink-0;
} }
.list-head { .list-head {
@@ -281,6 +318,11 @@
overflow-anchor: none; overflow-anchor: none;
} }
.list-item.duplicate-group-item {
background: hsl(var(--duplicate-group-hue) 84% 94%);
border-color: hsl(var(--duplicate-group-hue) 42% 78%);
}
.list-item::before { .list-item::before {
content: ''; content: '';
@apply absolute inset-0 opacity-0; @apply absolute inset-0 opacity-0;
@@ -361,6 +403,11 @@
box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04); box-shadow: 0 10px 22px rgba(15, 23, 42, 0.06), 0 0 0 1px rgba(37, 99, 235, 0.04);
} }
.list-item.duplicate-group-item:hover {
background: hsl(var(--duplicate-group-hue) 88% 92%);
border-color: hsl(var(--duplicate-group-hue) 52% 68%);
}
.list-item:hover::before { .list-item:hover::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);
@@ -372,6 +419,11 @@
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42); box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.42);
} }
.list-item.duplicate-group-item.active {
background: hsl(var(--duplicate-group-hue) 88% 89%);
border-color: hsl(var(--duplicate-group-hue) 58% 58%);
}
.list-item.active::before { .list-item.active::before {
opacity: 1; opacity: 1;
transform: translateX(0); transform: translateX(0);