Feature/semantic similarity (#36)

* fix: mixed up migrations

* feat: semantic similarity first take

* feat: semantic similarity first round of fixes

* feat: more work on making semantic similarity work properly

* feat: getPostBySlug for the AI

* feat: show similarity in post-link-insert-modal

* chore: remove done doc

---------

Co-authored-by: hugo <hugoms@me.com>
This commit is contained in:
Georg Bauer
2026-03-05 22:05:32 +01:00
committed by GitHub
parent 8ac8305e01
commit 7e1e8981a3
64 changed files with 6429 additions and 499 deletions

View File

@@ -4,6 +4,7 @@ import { useAppStore, PostData, MediaData, TaskProgress } from './store';
import { loadTabsForProject, saveTabsForProject } from './utils';
import { openSingletonToolTab } from './navigation/tabPolicy';
import { persistSiteValidationReport } from './navigation/siteValidationPersistence';
import { persistDuplicatesResult } from './navigation/duplicatesPersistence';
import { executeActivityClick } from './navigation/activityExecution';
import { handleBlogmarkCreatedEvent } from './navigation/blogmarkHandling';
import {
@@ -444,6 +445,25 @@ const App: React.FC = () => {
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:findDuplicates', () => {
openSingletonToolTab(openTab, 'find-duplicates');
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('embeddings:duplicateSearchResult', (...args: unknown[]) => {
const pairs = args[0] as import('../main/shared/electronApi').DuplicatePair[];
const projectId = useAppStore.getState().activeProject?.id;
if (projectId && pairs) {
persistDuplicatesResult(projectId, pairs);
window.dispatchEvent(new CustomEvent('bds:duplicates-updated', {
detail: { projectId },
}));
}
}) || (() => {})
);
unsubscribers.push(
window.electronAPI?.on('menu:generateSitemap', async () => {
try {

View File

@@ -22,10 +22,9 @@ interface ConfirmDeleteModalProps {
export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details, onClose }) => {
const { t: tr } = useI18n();
if (!details) return null;
const handleConfirm = useCallback(async () => {
await details.onConfirm();
await details?.onConfirm();
onClose();
}, [details, onClose]);
@@ -35,6 +34,8 @@ export const ConfirmDeleteModal: React.FC<ConfirmDeleteModalProps> = ({ details,
}
}, [onClose]);
if (!details) return null;
const hasReferences = details.references.length > 0;
return (

View File

@@ -0,0 +1,190 @@
.duplicates-view {
display: flex;
flex-direction: column;
gap: 16px;
padding: 16px;
height: 100%;
overflow: auto;
background: var(--vscode-editor-background);
color: var(--vscode-editor-foreground);
}
.duplicates-view-header h2 {
margin: 0 0 4px 0;
font-size: 1.1rem;
}
.duplicates-view-header p {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 0.875rem;
}
.duplicates-view-actions {
display: flex;
gap: 8px;
}
.duplicates-view-refresh {
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-refresh:hover:not(:disabled) {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.duplicates-view-refresh:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.duplicates-view-status {
margin: 0;
color: var(--vscode-descriptionForeground);
font-size: 0.875rem;
}
.duplicates-view-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.duplicate-pair {
display: flex;
align-items: center;
gap: 12px;
padding: 10px 12px;
background: var(--vscode-editorWidget-background);
border: 1px solid var(--vscode-editorWidget-border, var(--vscode-panel-border));
border-radius: 6px;
}
.duplicate-pair-posts {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
min-width: 0;
}
.duplicate-pair-post {
background: none;
border: none;
padding: 0;
cursor: pointer;
color: var(--vscode-textLink-foreground);
font-size: 0.875rem;
text-align: left;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.duplicate-pair-post:hover {
color: var(--vscode-textLink-activeForeground);
text-decoration: underline;
}
.duplicate-pair-separator {
font-size: 0.75rem;
color: var(--vscode-descriptionForeground);
padding-left: 2px;
}
.duplicate-pair-meta {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
flex-shrink: 0;
}
.duplicate-pair-score {
font-size: 0.8rem;
font-weight: 600;
color: var(--vscode-charts-orange, #f8ae4a);
white-space: nowrap;
}
.duplicate-pair-score--exact {
color: var(--vscode-errorForeground, #f44747);
}
.duplicate-pair--exact {
border-color: var(--vscode-errorForeground, #f44747);
}
.duplicate-pair-dismiss {
background: none;
border: 1px solid var(--vscode-button-border, var(--vscode-panel-border));
padding: 2px 8px;
border-radius: 4px;
cursor: pointer;
font-size: 0.75rem;
color: var(--vscode-descriptionForeground);
white-space: nowrap;
}
.duplicate-pair-dismiss:hover {
background-color: var(--vscode-list-hoverBackground);
color: var(--vscode-foreground);
}
.duplicates-view-not-enabled {
padding: 32px;
text-align: center;
color: var(--vscode-descriptionForeground);
}
.duplicates-view-count {
margin: 0;
font-size: 0.8rem;
color: var(--vscode-descriptionForeground);
}
.duplicates-view-show-more {
align-self: center;
background-color: var(--vscode-button-secondaryBackground);
color: var(--vscode-button-secondaryForeground);
border: none;
padding: 6px 20px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-show-more:hover {
background-color: var(--vscode-button-secondaryHoverBackground);
}
.duplicates-view-dismiss-checked {
background-color: var(--vscode-button-background);
color: var(--vscode-button-foreground);
border: none;
padding: 5px 12px;
border-radius: 4px;
cursor: pointer;
font-size: 0.875rem;
}
.duplicates-view-dismiss-checked:hover:not(:disabled) {
background-color: var(--vscode-button-hoverBackground);
}
.duplicates-view-dismiss-checked:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.duplicate-pair-checkbox {
flex-shrink: 0;
cursor: pointer;
accent-color: var(--vscode-focusBorder);
}

View File

@@ -0,0 +1,249 @@
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useAppStore } from '../../store';
import { openEntityTab } from '../../navigation/tabPolicy';
import { useI18n } from '../../i18n';
import { getPersistedDuplicatesResult, removeDismissedPair, removeDismissedPairs } from '../../navigation/duplicatesPersistence';
import type { DuplicatePair } from '../../../main/shared/electronApi';
import './DuplicatesView.css';
const PAGE_SIZE = 500;
function pairKey(pair: DuplicatePair): string {
return `${pair.postA.id}::${pair.postB.id}`;
}
export const DuplicatesView: React.FC = () => {
const { t } = useI18n();
const { openTab, activeProject } = useAppStore();
const [pairs, setPairs] = useState<DuplicatePair[]>([]);
const [isSearching, setIsSearching] = useState(false);
const [checkedKeys, setCheckedKeys] = useState<Set<string>>(new Set());
const [isDismissing, setIsDismissing] = useState(false);
const [notEnabled, setNotEnabled] = useState(false);
const [checked, setChecked] = useState(false);
const [visibleCount, setVisibleCount] = useState(PAGE_SIZE);
const projectId = activeProject?.id;
// Load persisted results (or check if feature is enabled)
useEffect(() => {
if (!projectId) return;
let cancelled = false;
(async () => {
const metadata = await window.electronAPI?.meta.getProjectMetadata();
if (cancelled) return;
if (!metadata?.semanticSimilarityEnabled) {
setNotEnabled(true);
setPairs([]);
setChecked(true);
return;
}
setNotEnabled(false);
const persisted = getPersistedDuplicatesResult(projectId);
if (persisted) {
setPairs(persisted);
}
setChecked(true);
})();
return () => { cancelled = true; };
}, [projectId]);
// Listen for search result updates from the background task
useEffect(() => {
const handler = (event: Event) => {
const detail = (event as CustomEvent<{ projectId?: string }>).detail;
if (!projectId || detail?.projectId !== projectId) return;
const persisted = getPersistedDuplicatesResult(projectId);
setPairs(persisted ?? []);
setIsSearching(false);
setVisibleCount(PAGE_SIZE);
setCheckedKeys(new Set());
};
window.addEventListener('bds:duplicates-updated', handler);
return () => window.removeEventListener('bds:duplicates-updated', handler);
}, [projectId]);
const visiblePairs = useMemo(() => pairs.slice(0, visibleCount), [pairs, visibleCount]);
const hasMore = visibleCount < pairs.length;
const handleRunSearch = useCallback(() => {
setIsSearching(true);
window.electronAPI?.embeddings.runDuplicateSearch(0.92);
}, []);
const handleDismiss = useCallback(async (postIdA: string, postIdB: string) => {
try {
await window.electronAPI?.embeddings.dismissPair(postIdA, postIdB);
setPairs(prev => prev.filter(p => !(p.postA.id === postIdA && p.postB.id === postIdB)));
setCheckedKeys(prev => { const next = new Set(prev); next.delete(`${postIdA}::${postIdB}`); return next; });
if (projectId) {
removeDismissedPair(projectId, postIdA, postIdB);
}
} catch (err) {
console.error('Failed to dismiss duplicate pair:', err);
}
}, [projectId]);
const handleDismissChecked = useCallback(async () => {
if (checkedKeys.size === 0) return;
setIsDismissing(true);
const pairIds: Array<[string, string]> = [];
for (const key of checkedKeys) {
const [a, b] = key.split('::') as [string, string];
pairIds.push([a, b]);
}
try {
await window.electronAPI?.embeddings.dismissPairs(pairIds);
setPairs(prev => prev.filter(p => !checkedKeys.has(pairKey(p))));
if (projectId) {
removeDismissedPairs(projectId, pairIds);
}
setCheckedKeys(new Set());
} catch (err) {
console.error('Failed to dismiss pairs:', err);
} finally {
setIsDismissing(false);
}
}, [checkedKeys, projectId]);
const handleToggleCheck = useCallback((key: string) => {
setCheckedKeys(prev => {
const next = new Set(prev);
if (next.has(key)) next.delete(key); else next.add(key);
return next;
});
}, []);
const handleCheckAll = useCallback(() => {
setCheckedKeys(new Set(visiblePairs.map(pairKey)));
}, [visiblePairs]);
const handleUncheckAll = useCallback(() => {
setCheckedKeys(new Set());
}, []);
const handleOpenPost = useCallback((postId: string) => {
openEntityTab(openTab, 'post', postId, 'pin');
}, [openTab]);
const handleShowMore = useCallback(() => {
setVisibleCount(prev => prev + PAGE_SIZE);
}, []);
const hasCachedResults = pairs.length > 0 || (checked && getPersistedDuplicatesResult(projectId ?? '') !== null);
return (
<div className="duplicates-view">
<div className="duplicates-view-header">
<h2>{t('duplicatesView.title')}</h2>
<p>{t('duplicatesView.description')}</p>
</div>
{notEnabled && (
<p className="duplicates-view-not-enabled">{t('duplicatesView.notEnabled')}</p>
)}
{!notEnabled && checked && (
<div className="duplicates-view-actions">
<button
type="button"
className="duplicates-view-refresh"
onClick={handleRunSearch}
disabled={isSearching}
>
{t('duplicatesView.refresh')}
</button>
{pairs.length > 0 && (
<>
<button type="button" className="duplicates-view-refresh" onClick={handleCheckAll}>
{t('duplicatesView.checkAll')}
</button>
<button type="button" className="duplicates-view-refresh" onClick={handleUncheckAll} disabled={checkedKeys.size === 0}>
{t('duplicatesView.uncheckAll')}
</button>
<button
type="button"
className="duplicates-view-dismiss-checked"
onClick={handleDismissChecked}
disabled={checkedKeys.size === 0 || isDismissing}
>
{t('duplicatesView.dismissChecked', { count: checkedKeys.size })}
</button>
</>
)}
</div>
)}
{!notEnabled && isSearching && (
<p className="duplicates-view-status">{t('duplicatesView.loading')}</p>
)}
{!notEnabled && !isSearching && checked && !hasCachedResults && (
<p className="duplicates-view-status">{t('duplicatesView.empty')}</p>
)}
{!notEnabled && !isSearching && pairs.length > 0 && (
<div className="duplicates-view-list">
<p className="duplicates-view-count">
{t('duplicatesView.count', { count: pairs.length })}
</p>
{visiblePairs.map(pair => {
const key = pairKey(pair);
return (
<div key={key} className={`duplicate-pair${pair.exactMatch ? ' duplicate-pair--exact' : ''}`}>
<input
type="checkbox"
className="duplicate-pair-checkbox"
checked={checkedKeys.has(key)}
onChange={() => handleToggleCheck(key)}
/>
<div className="duplicate-pair-posts">
<button
type="button"
className="duplicate-pair-post"
onClick={() => handleOpenPost(pair.postA.id)}
title={t('duplicatesView.openPost')}
>
{pair.postA.title || pair.postA.slug}
</button>
<span className="duplicate-pair-separator"></span>
<button
type="button"
className="duplicate-pair-post"
onClick={() => handleOpenPost(pair.postB.id)}
title={t('duplicatesView.openPost')}
>
{pair.postB.title || pair.postB.slug}
</button>
</div>
<div className="duplicate-pair-meta">
<span className={`duplicate-pair-score${pair.exactMatch ? ' duplicate-pair-score--exact' : ''}`}>
{pair.exactMatch
? t('duplicatesView.exactMatch')
: t('duplicatesView.similarity', { value: Math.round(pair.similarity * 100) })}
</span>
<button
type="button"
className="duplicate-pair-dismiss"
onClick={() => void handleDismiss(pair.postA.id, pair.postB.id)}
>
{t('duplicatesView.dismiss')}
</button>
</div>
</div>
);
})}
{hasMore && (
<button
type="button"
className="duplicates-view-show-more"
onClick={handleShowMore}
>
{t('duplicatesView.showMore')}
</button>
)}
</div>
)}
</div>
);
};

View File

@@ -21,6 +21,7 @@ import { DocumentationView } from '../DocumentationView/DocumentationView';
import { SiteValidationView } from '../SiteValidationView';
import { ScriptsView } from '../ScriptsView/ScriptsView';
import { TemplatesView } from '../TemplatesView/TemplatesView';
import { DuplicatesView } from '../DuplicatesView/DuplicatesView';
import { AutoSaveManager, getContrastColor, loadTagColorMap } from '../../utils';
import { InsertModal } from '../InsertModal';
import { AISuggestionsModal, AISuggestions } from '../AISuggestionsModal/AISuggestionsModal';
@@ -794,6 +795,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
value={tags}
onChange={setTags}
placeholder={tr('editor.placeholder.tags')}
postId={postId}
/>
</div>
<div className="editor-field">
@@ -1024,6 +1026,7 @@ export const PostEditor: React.FC<PostEditorProps> = ({ postId }) => {
onClose={() => setShowPostSearch(false)}
currentPostTags={tags}
currentPostCategories={selectedCategories}
currentPostId={postId}
/>
)}
@@ -1903,6 +1906,7 @@ export const Editor: React.FC = () => {
/>
),
'site-validation': () => <SiteValidationView />,
'find-duplicates': () => <DuplicatesView />,
scripts: () => <ScriptsView scriptId={editorRoute.tabId} />,
templates: () => <TemplatesView templateId={editorRoute.tabId} />,
post: () => (editorRoute.tabId ? <PostEditor key={editorRoute.tabId} postId={editorRoute.tabId} /> : <Dashboard />),

View File

@@ -140,6 +140,18 @@
font-family: 'Cascadia Code', 'Consolas', 'Courier New', monospace;
}
.insert-modal-similarity-badge {
display: inline-block;
margin-left: 8px;
padding: 1px 6px;
font-size: 11px;
font-weight: 500;
border-radius: 4px;
background: var(--color-bg-muted, rgba(255, 255, 255, 0.08));
color: var(--color-text-muted, #888);
vertical-align: middle;
}
.insert-modal-external {
padding: 20px;
display: flex;

View File

@@ -42,6 +42,7 @@ interface InsertModalProps {
initialText?: string; // Selected text in editor
currentPostTags?: string[];
currentPostCategories?: string[];
currentPostId?: string; // For semantic "related posts" suggestions
}
function isPostResult(result: SearchResult): result is PostSearchResult {
@@ -60,6 +61,7 @@ export const InsertModal: React.FC<InsertModalProps> = ({
initialText = '',
currentPostTags,
currentPostCategories,
currentPostId,
}) => {
const { t: tr } = useI18n();
const openTabInBackground = useAppStore((s) => s.openTabInBackground);
@@ -74,6 +76,42 @@ export const InsertModal: React.FC<InsertModalProps> = ({
const [isCreating, setIsCreating] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const externalUrlRef = useRef<HTMLInputElement>(null);
const [relatedPosts, setRelatedPosts] = useState<PostSearchResult[]>([]);
const [isLoadingRelated, setIsLoadingRelated] = useState(false);
const [similarityMap, setSimilarityMap] = useState<Record<string, number>>({});
// Load related posts via semantic similarity when idle (query < 2 chars)
useEffect(() => {
if (mode !== 'link' || !currentPostId || activeTab !== 'internal' || query.length >= 2) {
setRelatedPosts([]);
return;
}
let cancelled = false;
setIsLoadingRelated(true);
(async () => {
try {
const similar = await window.electronAPI.embeddings.findSimilar(currentPostId, 5);
if (cancelled || similar.length === 0) { setRelatedPosts([]); return; }
const posts = await Promise.all(similar.map(s => window.electronAPI.posts.get(s.postId)));
if (!cancelled) {
// Store similarity scores
const simMap: Record<string, number> = {};
for (const s of similar) { simMap[s.postId] = s.similarity; }
setSimilarityMap(simMap);
setRelatedPosts(
posts.filter((p): p is NonNullable<typeof p> => p != null).map(p => ({
id: p.id, title: p.title, slug: p.slug, excerpt: p.excerpt,
})),
);
}
} catch {
if (!cancelled) setRelatedPosts([]);
} finally {
if (!cancelled) setIsLoadingRelated(false);
}
})();
return () => { cancelled = true; };
}, [currentPostId, mode, activeTab, query]);
// Whether to show the "Create post" option
const showCreateOption = mode === 'link' &&
@@ -124,6 +162,22 @@ export const InsertModal: React.FC<InsertModalProps> = ({
return () => clearTimeout(timeoutId);
}, [query, mode, activeTab]);
// Fetch similarity scores for search results relative to current post
useEffect(() => {
if (mode !== 'link' || !currentPostId || results.length === 0) return;
const postResults = results.filter(isPostResult);
if (postResults.length === 0) return;
let cancelled = false;
(async () => {
try {
const targetIds = postResults.map(r => r.id);
const sims = await window.electronAPI.embeddings.computeSimilarities(currentPostId, targetIds);
if (!cancelled) setSimilarityMap(prev => ({ ...prev, ...sims }));
} catch { /* ignore */ }
})();
return () => { cancelled = true; };
}, [results, currentPostId, mode]);
// Handle creating a new post from the search query
const handleCreatePost = useCallback(async () => {
const title = query.trim();
@@ -284,12 +338,43 @@ export const InsertModal: React.FC<InsertModalProps> = ({
<div className="insert-modal-status">{tr('insert.status.searching')}</div>
)}
{!isSearching && query.length < 2 && (
{!isSearching && query.length < 2 && relatedPosts.length === 0 && !isLoadingRelated && (
<div className="insert-modal-status">
{tr('insert.status.typeMore')}
</div>
)}
{!isSearching && query.length < 2 && isLoadingRelated && (
<div className="insert-modal-status">{tr('insert.status.loadingRelated')}</div>
)}
{!isSearching && query.length < 2 && relatedPosts.length > 0 && (
<>
<div className="insert-modal-section-label">{tr('insert.section.relatedPosts')}</div>
{relatedPosts.map((result, index) => (
<div
key={result.id}
className={`insert-modal-result-item ${index === selectedIndex ? 'selected' : ''}`}
onClick={() => handleSelectResult(result)}
onMouseEnter={() => setSelectedIndex(index)}
>
<div className="insert-modal-result-title">
{result.title}
{similarityMap[result.id] != null && (
<span className="insert-modal-similarity-badge">{Math.round(similarityMap[result.id]! * 100)}%</span>
)}
</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120 ? result.excerpt.substring(0, 120) + '...' : result.excerpt}
</div>
)}
<div className="insert-modal-result-path">/posts/{result.slug}</div>
</div>
))}
</>
)}
{!isSearching && query.length >= 2 && results.length === 0 && !showCreateOption && (
<div className="insert-modal-status">
{tr('insert.status.noResults', { kind: mode === 'link' ? tr('activity.posts').toLowerCase() : tr('activity.media').toLowerCase(), query })}
@@ -305,7 +390,12 @@ export const InsertModal: React.FC<InsertModalProps> = ({
>
{isPostResult(result) ? (
<>
<div className="insert-modal-result-title">{result.title}</div>
<div className="insert-modal-result-title">
{result.title}
{currentPostId && similarityMap[result.id] != null && (
<span className="insert-modal-similarity-badge">{Math.round(similarityMap[result.id]! * 100)}%</span>
)}
</div>
{result.excerpt && (
<div className="insert-modal-result-excerpt">
{result.excerpt.length > 120

View File

@@ -226,6 +226,7 @@ export const SettingsView: React.FC = () => {
const [projectMaxPostsPerPage, setProjectMaxPostsPerPage] = useState(50);
const [projectBlogmarkCategory, setProjectBlogmarkCategory] = useState('article');
const [projectPythonRuntimeMode, setProjectPythonRuntimeMode] = useState<'webworker' | 'main-thread'>('webworker');
const [semanticSimilarityEnabled, setSemanticSimilarityEnabled] = useState(false);
// Post categories management
const [postCategories, setPostCategories] = useState<string[]>(DEFAULT_POST_CATEGORIES);
@@ -314,6 +315,9 @@ export const SettingsView: React.FC = () => {
const incomingPythonRuntimeMode = (metadata as { pythonRuntimeMode?: unknown } | null)?.pythonRuntimeMode;
setProjectPythonRuntimeMode(incomingPythonRuntimeMode === 'main-thread' ? 'main-thread' : 'webworker');
const incomingSemanticSimilarity = (metadata as { semanticSimilarityEnabled?: unknown } | null)?.semanticSimilarityEnabled;
setSemanticSimilarityEnabled(incomingSemanticSimilarity === true);
const incomingCategoryMetadata = (metadata as any)?.categoryMetadata as Record<string, CategoryMetadata> | undefined;
const incomingLegacyCategorySettings = (metadata as any)?.categorySettings as Record<string, { renderInLists: boolean; showTitle: boolean }> | undefined;
setCategoryMetadata((current) => {
@@ -545,6 +549,7 @@ export const SettingsView: React.FC = () => {
maxPostsPerPage: Math.min(500, Math.max(1, Math.floor(projectMaxPostsPerPage || 50))),
blogmarkCategory: normalizeBlogmarkCategory(projectBlogmarkCategory) || undefined,
pythonRuntimeMode: projectPythonRuntimeMode,
semanticSimilarityEnabled,
categoryMetadata,
});
}
@@ -592,7 +597,7 @@ export const SettingsView: React.FC = () => {
const editorKeywords = ['editor', 'mode', 'wysiwyg', 'markdown', 'preview', 'visual'];
const contentKeywords = ['content', 'categories', 'post', 'article', 'picture', 'aside', 'page'];
const aiKeywords = ['ai', 'assistant', 'chat', 'model', 'prompt', 'system', 'api', 'key', 'claude', 'gpt', 'opencode', 'ollama', 'lmstudio', 'lm studio', 'local'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution'];
const technologyKeywords = ['technology', 'python', 'runtime', 'worker', 'webworker', 'main thread', 'execution', 'semantic', 'similarity', 'embedding', 'ai', 'search', 'duplicate'];
const publishingKeywords = ['publishing', 'ssh', 'deploy', 'server', 'host', 'upload', 'scp', 'rsync'];
const dataKeywords = ['data', 'database', 'rebuild', 'maintenance', 'posts', 'media', 'scripts', 'links', 'folder', 'filesystem'];
const mcpKeywords = ['mcp', 'server', 'agent', 'claude', 'copilot', 'gemini', 'opencode', 'model context protocol', 'coding', 'configuration'];
@@ -1823,6 +1828,23 @@ export const SettingsView: React.FC = () => {
<option value="main-thread">{t('settings.technology.pythonRuntimeMode.mainThread')}</option>
</select>
</SettingRow>
<SettingRow
id="semantic-similarity-enabled"
label={t('settings.technology.semanticSimilarityLabel')}
description={t('settings.technology.semanticSimilarityDescription')}
>
<input
id="semantic-similarity-enabled"
type="checkbox"
checked={semanticSimilarityEnabled}
onChange={(e) => {
const checked = e.target.checked;
setSemanticSimilarityEnabled(checked);
window.electronAPI?.meta.updateProjectMetadata({ semanticSimilarityEnabled: checked }).catch(() => {});
}}
/>
</SettingRow>
</SettingSection>
);

View File

@@ -87,6 +87,10 @@ const getTabTitle = (
return tr('siteValidation.tabTitle');
}
if (tab.type === 'find-duplicates') {
return tr('duplicatesView.tabTitle');
}
if (tab.type === 'scripts') {
return scriptTitles.get(tab.id) || tr('tabBar.scripts');
}
@@ -180,6 +184,12 @@ const getTabIcon = (tab: Tab): React.ReactNode => {
<path d="M8 1.5a6.5 6.5 0 1 0 6.5 6.5A6.5 6.5 0 0 0 8 1.5zm0 1a5.5 5.5 0 0 1 4.39 8.82l-.88-.88a.5.5 0 0 0-.7.7l.8.8A5.5 5.5 0 1 1 8 2.5zm2.35 3.15L7 9 5.65 7.65a.5.5 0 1 0-.7.7l1.7 1.7a.5.5 0 0 0 .7 0l3.7-3.7a.5.5 0 1 0-.7-.7z"/>
</svg>
);
case 'find-duplicates':
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor">
<path d="M1 2.5A1.5 1.5 0 0 1 2.5 1h3A1.5 1.5 0 0 1 7 2.5V5H9V2.5A1.5 1.5 0 0 1 10.5 1h3A1.5 1.5 0 0 1 15 2.5v3A1.5 1.5 0 0 1 13.5 7H11v2h2.5A1.5 1.5 0 0 1 15 10.5v3A1.5 1.5 0 0 1 13.5 15h-3A1.5 1.5 0 0 1 9 13.5V11H7v2.5A1.5 1.5 0 0 1 5.5 15h-3A1.5 1.5 0 0 1 1 13.5v-3A1.5 1.5 0 0 1 2.5 9H5V7H2.5A1.5 1.5 0 0 1 1 5.5v-3zM2 2.5v3a.5.5 0 0 0 .5.5H5V2H2.5a.5.5 0 0 0-.5.5zm8 0V6h2.5a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5H10.5a.5.5 0 0 0-.5.5zM2 10.5v3a.5.5 0 0 0 .5.5H5v-4H2.5a.5.5 0 0 0-.5.5zm8 3v-4h-.5a.5.5 0 0 0-.5.5v3a.5.5 0 0 0 .5.5H13.5a.5.5 0 0 0 .5-.5v-3a.5.5 0 0 0-.5-.5H11z"/>
</svg>
);
case 'scripts':
return (
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor">

View File

@@ -22,6 +22,8 @@ interface TagInputProps {
disabled?: boolean;
/** Input mode (tags or categories) */
mode?: 'tag' | 'category';
/** Post ID for AI-based tag suggestions (semantic similarity) */
postId?: string;
}
export const TagInput: React.FC<TagInputProps> = ({
@@ -30,6 +32,7 @@ export const TagInput: React.FC<TagInputProps> = ({
placeholder = 'Add tags...',
disabled = false,
mode = 'tag',
postId,
}) => {
const { t } = useI18n();
const [inputValue, setInputValue] = useState('');
@@ -38,7 +41,8 @@ export const TagInput: React.FC<TagInputProps> = ({
const [showSuggestions, setShowSuggestions] = useState(false);
const [selectedIndex, setSelectedIndex] = useState(-1);
const [isCreating, setIsCreating] = useState(false);
const [aiSuggestedTags, setAiSuggestedTags] = useState<string[]>([]);
const inputRef = useRef<HTMLInputElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
@@ -92,6 +96,26 @@ export const TagInput: React.FC<TagInputProps> = ({
setSelectedIndex(-1);
}, [inputValue, allTags, value]);
// Load AI tag suggestions when focused on an empty input (mode=tag only)
useEffect(() => {
if (mode !== 'tag' || !postId || inputValue.trim()) {
setAiSuggestedTags([]);
return;
}
let cancelled = false;
(async () => {
try {
const suggestions = await window.electronAPI.embeddings.suggestTags(postId, value);
if (!cancelled) {
setAiSuggestedTags(suggestions.map(s => s.name));
}
} catch {
if (!cancelled) setAiSuggestedTags([]);
}
})();
return () => { cancelled = true; };
}, [postId, mode, value, inputValue]);
// Close suggestions when clicking outside
useEffect(() => {
const handleClickOutside = (event: MouseEvent) => {
@@ -273,8 +297,25 @@ export const TagInput: React.FC<TagInputProps> = ({
</div>
{/* Suggestions dropdown */}
{showSuggestions && (suggestions.length > 0 || showCreateOption) && (
{showSuggestions && (suggestions.length > 0 || showCreateOption || aiSuggestedTags.length > 0) && (
<div className="tag-suggestions">
{/* AI-suggested tags shown when input is empty */}
{!inputValue.trim() && aiSuggestedTags.length > 0 && (
<>
<div className="tag-suggestion-section-label">{t('tagInput.aiSuggestedLabel')}</div>
{aiSuggestedTags.map((tagName) => (
<button
key={`ai-${tagName}`}
type="button"
className="tag-suggestion ai-suggested"
onClick={() => addTag(tagName)}
>
<span className="tag-suggestion-name">{tagName}</span>
</button>
))}
{suggestions.length > 0 && <div className="tag-suggestion-section-label">{t('tagInput.allTagsLabel')}</div>}
</>
)}
{suggestions.map((tag, index) => {
const hasColor = !!tag.color;
const style: React.CSSProperties = hasColor

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Lege fest, wo Python-Skripte für Transformationspipelines ausgeführt werden.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (empfohlen)",
"settings.technology.pythonRuntimeMode.mainThread": "Hauptthread (Legacy)",
"settings.technology.semanticSimilarityLabel": "Semantische Ähnlichkeit",
"settings.technology.semanticSimilarityDescription": "Aktiviert lokale KI-Einbettungen für Vorschläge zu verwandten Beiträgen, Tag-Hinweise und Erkennung von Duplikaten. Lädt beim ersten Start ein ~100 MB großes Modell herunter.",
"settings.publishing.sshTitle": "SSH-Veröffentlichung",
"settings.data.title": "Datenbankwartung",
"settings.data.fileSystemTitle": "Dateisystem",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Medien nach Name, Titel oder Alt-Text durchsuchen...",
"insert.status.searching": "Suche...",
"insert.status.typeMore": "Zum Suchen mindestens 2 Zeichen eingeben",
"insert.status.loadingRelated": "Ähnliche Beiträge werden geladen...",
"insert.section.relatedPosts": "Verwandte Beiträge",
"insert.status.noResults": "Keine {kind} für \"{query}\" gefunden",
"insert.label.url": "Webadresse",
"insert.label.linkTextOptional": "Linktext (optional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Assistent-Sitzung",
"assistantSidebar.error.startFailed": "Assistent-Sitzung konnte nicht gestartet werden",
"assistantSidebar.error.actionFailed": "Assistent-Aktion konnte nicht ausgeführt werden",
"tagInput.aiSuggestedLabel": "KI-Vorschläge",
"tagInput.allTagsLabel": "Alle Tags",
"tagInput.alreadyAdded": "Tag bereits hinzugefügt",
"tagInput.remove": "{tag} entfernen",
"tagInput.createdTag": "Tag \"{name}\" erstellt",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP-Server aus der {agent}-Konfiguration entfernt",
"settings.toast.mcpConfigFailed": "Konfiguration von {agent} fehlgeschlagen: {error}",
"settings.toast.mcpConfigRemoveFailed": "Entfernen aus {agent} fehlgeschlagen: {error}",
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}"
"settings.toast.mcpConfigPath": "Konfiguration geschrieben nach {path}",
"duplicatesView.tabTitle": "Duplikate finden",
"duplicatesView.title": "Doppelte Beiträge",
"duplicatesView.description": "Beiträge mit hoher inhaltlicher Ähnlichkeit, die möglicherweise Duplikate sind.",
"duplicatesView.loading": "Suche nach Duplikaten...",
"duplicatesView.empty": "Keine doppelten Beiträge gefunden.",
"duplicatesView.error": "Duplikate konnten nicht geladen werden",
"duplicatesView.refresh": "Aktualisieren",
"duplicatesView.dismiss": "Ignorieren",
"duplicatesView.similarity": "{value}% ähnlich",
"duplicatesView.exactMatch": "Exaktes Duplikat",
"duplicatesView.openPost": "Beitrag öffnen",
"duplicatesView.count": "{count} Paare gefunden",
"duplicatesView.showMore": "Mehr anzeigen",
"duplicatesView.checkAll": "Alle auswählen",
"duplicatesView.uncheckAll": "Alle abwählen",
"duplicatesView.dismissChecked": "Ausgewählte ignorieren ({count})",
"duplicatesView.notEnabled": "Semantische Ähnlichkeit ist nicht aktiviert. Aktivieren Sie sie unter Einstellungen → Technologie."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Choose where Python scripts execute for transform pipelines.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (Recommended)",
"settings.technology.pythonRuntimeMode.mainThread": "Main Thread (Legacy)",
"settings.technology.semanticSimilarityLabel": "Semantic Similarity",
"settings.technology.semanticSimilarityDescription": "Enable local AI embeddings for related-post suggestions, tag hints, and duplicate detection. Downloads a ~100 MB model on first use.",
"settings.publishing.sshTitle": "SSH Publishing",
"settings.data.title": "Database Maintenance",
"settings.data.fileSystemTitle": "File System",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Search media by name, title, or alt text...",
"insert.status.searching": "Searching...",
"insert.status.typeMore": "Type at least 2 characters to search",
"insert.status.loadingRelated": "Loading related posts...",
"insert.section.relatedPosts": "Related Posts",
"insert.status.noResults": "No {kind} found for \"{query}\"",
"insert.label.url": "URL",
"insert.label.linkTextOptional": "Link Text (optional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Assistant Session",
"assistantSidebar.error.startFailed": "Failed to start assistant session",
"assistantSidebar.error.actionFailed": "Assistant action could not be executed",
"tagInput.aiSuggestedLabel": "AI Suggestions",
"tagInput.allTagsLabel": "All Tags",
"tagInput.alreadyAdded": "Tag already added",
"tagInput.remove": "Remove {tag}",
"tagInput.createdTag": "Tag \"{name}\" created",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "bDS MCP server removed from {agent} configuration",
"settings.toast.mcpConfigFailed": "Failed to configure {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Failed to remove from {agent}: {error}",
"settings.toast.mcpConfigPath": "Config written to {path}"
"settings.toast.mcpConfigPath": "Config written to {path}",
"duplicatesView.tabTitle": "Find Duplicates",
"duplicatesView.title": "Duplicate Posts",
"duplicatesView.description": "Posts with high content similarity that may be duplicates.",
"duplicatesView.loading": "Searching for duplicates...",
"duplicatesView.empty": "No duplicate posts found.",
"duplicatesView.error": "Failed to load duplicates",
"duplicatesView.refresh": "Refresh",
"duplicatesView.dismiss": "Dismiss",
"duplicatesView.similarity": "{value}% similar",
"duplicatesView.exactMatch": "Exact duplicate",
"duplicatesView.openPost": "Open post",
"duplicatesView.count": "{count} pairs found",
"duplicatesView.showMore": "Show more",
"duplicatesView.checkAll": "Check All",
"duplicatesView.uncheckAll": "Uncheck All",
"duplicatesView.dismissChecked": "Dismiss Checked ({count})",
"duplicatesView.notEnabled": "Semantic similarity is not enabled. Enable it in Settings → Technology."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Elige dónde se ejecutan los scripts de Python para los flujos de transformación.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recomendado)",
"settings.technology.pythonRuntimeMode.mainThread": "Hilo principal (heredado)",
"settings.technology.semanticSimilarityLabel": "Similitud semántica",
"settings.technology.semanticSimilarityDescription": "Activa incrustaciones de IA locales para sugerencias de publicaciones relacionadas, sugerencias de etiquetas y detección de duplicados. Descarga un modelo de ~100 MB en el primer uso.",
"settings.publishing.sshTitle": "Publicación SSH",
"settings.data.title": "Mantenimiento de base de datos",
"settings.data.fileSystemTitle": "Sistema de archivos",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Buscar medios por nombre, título o texto alternativo...",
"insert.status.searching": "Buscando...",
"insert.status.typeMore": "Escribe al menos 2 caracteres para buscar",
"insert.status.loadingRelated": "Cargando publicaciones relacionadas...",
"insert.section.relatedPosts": "Publicaciones relacionadas",
"insert.status.noResults": "No se encontró {kind} para \"{query}\"",
"insert.label.url": "Dirección URL",
"insert.label.linkTextOptional": "Texto del enlace (opcional)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Sesión de asistente",
"assistantSidebar.error.startFailed": "No se pudo iniciar la sesión del asistente",
"assistantSidebar.error.actionFailed": "No se pudo ejecutar la acción del asistente",
"tagInput.aiSuggestedLabel": "Sugerencias IA",
"tagInput.allTagsLabel": "Todos los tags",
"tagInput.alreadyAdded": "La etiqueta “{tag}” ya está añadida",
"tagInput.remove": "Quitar",
"tagInput.createdTag": "Etiqueta “{tag}” creada",
@@ -1092,5 +1098,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Servidor MCP de bDS eliminado de la configuración de {agent}",
"settings.toast.mcpConfigFailed": "Error al configurar {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Error al eliminar de {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuración escrita en {path}"
"settings.toast.mcpConfigPath": "Configuración escrita en {path}",
"duplicatesView.tabTitle": "Buscar duplicados",
"duplicatesView.title": "Entradas duplicadas",
"duplicatesView.description": "Entradas con alta similitud de contenido que pueden ser duplicadas.",
"duplicatesView.loading": "Buscando duplicados...",
"duplicatesView.empty": "No se encontraron entradas duplicadas.",
"duplicatesView.error": "Error al cargar duplicados",
"duplicatesView.refresh": "Actualizar",
"duplicatesView.dismiss": "Descartar",
"duplicatesView.similarity": "{value}% similar",
"duplicatesView.exactMatch": "Duplicado exacto",
"duplicatesView.openPost": "Abrir entrada",
"duplicatesView.count": "{count} pares encontrados",
"duplicatesView.showMore": "Mostrar más",
"duplicatesView.checkAll": "Seleccionar todo",
"duplicatesView.uncheckAll": "Deseleccionar todo",
"duplicatesView.dismissChecked": "Descartar seleccionados ({count})",
"duplicatesView.notEnabled": "La similitud semántica no está activada. Actívela en Configuración → Tecnología."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Choisissez où les scripts Python sexécutent pour les pipelines de transformation.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (recommandé)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principal (hérité)",
"settings.technology.semanticSimilarityLabel": "Similarité sémantique",
"settings.technology.semanticSimilarityDescription": "Active les embeddings IA locaux pour les suggestions de publications similaires, les suggestions de tags et la détection de doublons. Télécharge un modèle d'environ 100 Mo lors du premier usage.",
"settings.publishing.sshTitle": "Publication SSH",
"settings.data.title": "Maintenance de la base de données",
"settings.data.fileSystemTitle": "Système de fichiers",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Rechercher des médias par nom, titre ou texte alternatif...",
"insert.status.searching": "Recherche...",
"insert.status.typeMore": "Saisissez au moins 2 caractères pour rechercher",
"insert.status.loadingRelated": "Chargement des publications connexes...",
"insert.section.relatedPosts": "Publications connexes",
"insert.status.noResults": "Aucun(e) {kind} trouvé(e) pour \"{query}\"",
"insert.label.url": "Adresse URL",
"insert.label.linkTextOptional": "Texte du lien (optionnel)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Session Assistant",
"assistantSidebar.error.startFailed": "Impossible de démarrer la session assistant",
"assistantSidebar.error.actionFailed": "Laction assistant na pas pu être exécutée",
"tagInput.aiSuggestedLabel": "Suggestions IA",
"tagInput.allTagsLabel": "Tous les tags",
"tagInput.alreadyAdded": "Le tag « {tag} » est déjà ajouté",
"tagInput.remove": "Supprimer",
"tagInput.createdTag": "Tag « {tag} » créé",
@@ -1090,5 +1096,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Serveur MCP bDS retiré de la configuration de {agent}",
"settings.toast.mcpConfigFailed": "Échec de la configuration de {agent}: {error}",
"settings.toast.mcpConfigRemoveFailed": "Échec du retrait de {agent}: {error}",
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}"
"settings.toast.mcpConfigPath": "Configuration écrite dans {path}",
"duplicatesView.tabTitle": "Trouver les doublons",
"duplicatesView.title": "Articles en double",
"duplicatesView.description": "Articles avec une grande similarité de contenu pouvant être des doublons.",
"duplicatesView.loading": "Recherche des doublons...",
"duplicatesView.empty": "Aucun article en double trouvé.",
"duplicatesView.error": "Impossible de charger les doublons",
"duplicatesView.refresh": "Actualiser",
"duplicatesView.dismiss": "Ignorer",
"duplicatesView.similarity": "{value}% similaire",
"duplicatesView.exactMatch": "Doublon exact",
"duplicatesView.openPost": "Ouvrir l'article",
"duplicatesView.count": "{count} paires trouvées",
"duplicatesView.showMore": "Afficher plus",
"duplicatesView.checkAll": "Tout cocher",
"duplicatesView.uncheckAll": "Tout décocher",
"duplicatesView.dismissChecked": "Ignorer cochés ({count})",
"duplicatesView.notEnabled": "La similarité sémantique n'est pas activée. Activez-la dans Paramètres → Technologie."
}

View File

@@ -139,6 +139,8 @@
"settings.technology.pythonRuntimeModeDescription": "Scegli dove eseguire gli script Python per le pipeline di trasformazione.",
"settings.technology.pythonRuntimeMode.webworker": "Web Worker (consigliato)",
"settings.technology.pythonRuntimeMode.mainThread": "Thread principale (legacy)",
"settings.technology.semanticSimilarityLabel": "Similarità semantica",
"settings.technology.semanticSimilarityDescription": "Abilita gli embedding AI locali per suggerimenti di post correlati, suggerimenti di tag e rilevamento di duplicati. Scarica un modello di circa 100 MB al primo utilizzo.",
"settings.publishing.sshTitle": "Pubblicazione SSH",
"settings.data.title": "Manutenzione database",
"settings.data.fileSystemTitle": "Sistema file",
@@ -237,6 +239,8 @@
"insert.searchPlaceholder.image": "Cerca media per nome, titolo o testo alternativo...",
"insert.status.searching": "Ricerca...",
"insert.status.typeMore": "Digita almeno 2 caratteri per cercare",
"insert.status.loadingRelated": "Caricamento post correlati...",
"insert.section.relatedPosts": "Post correlati",
"insert.status.noResults": "Nessun {kind} trovato per \"{query}\"",
"insert.label.url": "Indirizzo URL",
"insert.label.linkTextOptional": "Testo link (opzionale)",
@@ -977,6 +981,8 @@
"assistantSidebar.conversationTitle": "Sessione assistente",
"assistantSidebar.error.startFailed": "Impossibile avviare la sessione assistente",
"assistantSidebar.error.actionFailed": "Impossibile eseguire lazione dellassistente",
"tagInput.aiSuggestedLabel": "Suggerimenti IA",
"tagInput.allTagsLabel": "Tutti i tag",
"tagInput.alreadyAdded": "Il tag “{tag}” è già stato aggiunto",
"tagInput.remove": "Rimuovi",
"tagInput.createdTag": "Tag “{tag}” creato",
@@ -1090,5 +1096,22 @@
"settings.toast.mcpConfigRemoveSuccess": "Server MCP bDS rimosso dalla configurazione di {agent}",
"settings.toast.mcpConfigFailed": "Configurazione di {agent} non riuscita: {error}",
"settings.toast.mcpConfigRemoveFailed": "Rimozione da {agent} non riuscita: {error}",
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}"
"settings.toast.mcpConfigPath": "Configurazione scritta in {path}",
"duplicatesView.tabTitle": "Trova duplicati",
"duplicatesView.title": "Post duplicati",
"duplicatesView.description": "Post con elevata similitudine di contenuto che potrebbero essere duplicati.",
"duplicatesView.loading": "Ricerca duplicati...",
"duplicatesView.empty": "Nessun post duplicato trovato.",
"duplicatesView.error": "Impossibile caricare i duplicati",
"duplicatesView.refresh": "Aggiorna",
"duplicatesView.dismiss": "Ignora",
"duplicatesView.similarity": "{value}% simile",
"duplicatesView.exactMatch": "Duplicato esatto",
"duplicatesView.openPost": "Apri post",
"duplicatesView.count": "{count} coppie trovate",
"duplicatesView.showMore": "Mostra altri",
"duplicatesView.checkAll": "Seleziona tutto",
"duplicatesView.uncheckAll": "Deseleziona tutto",
"duplicatesView.dismissChecked": "Ignora selezionati ({count})",
"duplicatesView.notEnabled": "La similarità semantica non è abilitata. Abilitala in Impostazioni → Tecnologia."
}

View File

@@ -0,0 +1,30 @@
import type { DuplicatePair } from '../../main/shared/electronApi';
const store = new Map<string, DuplicatePair[]>();
export function persistDuplicatesResult(projectId: string, pairs: DuplicatePair[]): void {
store.set(projectId, pairs);
}
export function getPersistedDuplicatesResult(projectId: string): DuplicatePair[] | null {
return store.get(projectId) ?? null;
}
export function removeDismissedPair(projectId: string, postIdA: string, postIdB: string): void {
const pairs = store.get(projectId);
if (!pairs) return;
store.set(
projectId,
pairs.filter(p => !(p.postA.id === postIdA && p.postB.id === postIdB)),
);
}
export function removeDismissedPairs(projectId: string, pairIds: Array<[string, string]>): void {
const pairs = store.get(projectId);
if (!pairs) return;
const keySet = new Set(pairIds.map(([a, b]) => `${a}::${b}`));
store.set(
projectId,
pairs.filter(p => !keySet.has(`${p.postA.id}::${p.postB.id}`)),
);
}

View File

@@ -17,7 +17,8 @@ export type EditorRoute =
| 'api-documentation'
| 'site-validation'
| 'scripts'
| 'templates';
| 'templates'
| 'find-duplicates';
export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'dashboard'>> = {
post: 'post',
@@ -35,6 +36,7 @@ export const EDITOR_TAB_ROUTE_REGISTRY: Record<TabType, Exclude<EditorRoute, 'da
'site-validation': 'site-validation',
scripts: 'scripts',
templates: 'templates',
'find-duplicates': 'find-duplicates',
};
export interface EditorRouteResolution {

View File

@@ -9,7 +9,8 @@ export type SingletonToolTabKey =
| 'documentation'
| 'api-documentation'
| 'metadata-diff'
| 'site-validation';
| 'site-validation'
| 'find-duplicates';
export interface CanonicalTabSpec {
type: TabType;
@@ -33,6 +34,7 @@ const SINGLETON_TOOL_TAB_REGISTRY: Record<SingletonToolTabKey, CanonicalTabSpec>
'api-documentation': { type: 'api-documentation', id: 'api-documentation', isTransient: false },
'metadata-diff': { type: 'metadata-diff', id: 'metadata-diff', isTransient: false },
'site-validation': { type: 'site-validation', id: 'site-validation', isTransient: false },
'find-duplicates': { type: 'find-duplicates', id: 'find-duplicates', isTransient: false },
};
export function getSingletonToolTabSpec(key: SingletonToolTabKey): CanonicalTabSpec {

View File

@@ -13,7 +13,7 @@ import type {
const STORAGE_KEY = 'bds-app-state';
// Tab types
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates';
export type TabType = 'post' | 'media' | 'settings' | 'style' | 'tags' | 'chat' | 'import' | 'menu-editor' | 'metadata-diff' | 'git-diff' | 'documentation' | 'api-documentation' | 'site-validation' | 'scripts' | 'templates' | 'find-duplicates';
export interface Tab {
type: TabType;