diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 724577d..757e20b 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -229,6 +229,15 @@ export const LOCAL_SCHEMA_DDL_SQL = ` captured_at INTEGER NOT NULL ); + CREATE TABLE IF NOT EXISTS contact_tags ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(contact_id, tag) + ); + + CREATE INDEX IF NOT EXISTS idx_contact_tags_contact_id ON contact_tags(contact_id); CREATE INDEX IF NOT EXISTS idx_contact_emails_contact_id ON contact_emails(contact_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_contact_emails_contact_email ON contact_emails(contact_id, email); CREATE INDEX IF NOT EXISTS idx_contact_phones_contact_id ON contact_phones(contact_id); diff --git a/src/main/database/shared-schema.ts b/src/main/database/shared-schema.ts index ee059d8..63c081a 100644 --- a/src/main/database/shared-schema.ts +++ b/src/main/database/shared-schema.ts @@ -56,6 +56,14 @@ export const SHARED_SCHEMA_SQL = ` created_at INTEGER NOT NULL DEFAULT 0 ); + CREATE TABLE IF NOT EXISTS contact_tags ( + id TEXT PRIMARY KEY, + contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, + tag TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0, + UNIQUE(contact_id, tag) + ); + CREATE TABLE IF NOT EXISTS contact_alert_rss ( id TEXT PRIMARY KEY, contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE, @@ -109,6 +117,7 @@ export const SHARED_SCHEMA_SQL = ` value TEXT NOT NULL ); + CREATE INDEX IF NOT EXISTS idx_shared_contact_tags_contact_id ON contact_tags(contact_id); CREATE INDEX IF NOT EXISTS idx_shared_contact_emails_contact_id ON contact_emails(contact_id); CREATE UNIQUE INDEX IF NOT EXISTS idx_shared_contact_emails_contact_email ON contact_emails(contact_id, email); CREATE INDEX IF NOT EXISTS idx_shared_contact_phones_contact_id ON contact_phones(contact_id); diff --git a/src/main/ipc/contacts.ts b/src/main/ipc/contacts.ts index 0180767..3d4e014 100644 --- a/src/main/ipc/contacts.ts +++ b/src/main/ipc/contacts.ts @@ -174,7 +174,8 @@ export function registerContactHandlers(): void { FROM (SELECT pm.project_id AS pid, p.name AS pname FROM project_memberships pm JOIN projects p ON p.id = pm.project_id WHERE pm.contact_id = c.id - ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw + ORDER BY p.name COLLATE NOCASE ASC)) AS projects_raw, + (SELECT GROUP_CONCAT(tag, '\x1f') FROM (SELECT tag FROM contact_tags WHERE contact_id = c.id ORDER BY created_at)) AS tags_raw FROM contacts c ORDER BY c.name COLLATE NOCASE ASC`, ) @@ -192,6 +193,7 @@ export function registerContactHandlers(): void { date_first_contacted: number | null; date_last_contacted: number | null; projects_raw: string | null; + tags_raw: string | null; }>; return rows.map((row) => ({ @@ -210,6 +212,7 @@ export function registerContactHandlers(): void { projects: row.projects_raw ? (JSON.parse(row.projects_raw) as { id: string; name: string }[]) : [], + tags: row.tags_raw ? row.tags_raw.split('\x1f') : [], })); }); @@ -267,7 +270,10 @@ export function registerContactHandlers(): void { reporters: allReporters.filter((r) => r.membership_id === p.membership_id), })); - return { ...contact, emails, phones, links, handles, projects: projectsWithReporters } as ContactDetail; + const tags = (stmt(db, 'SELECT tag FROM contact_tags WHERE contact_id = ? ORDER BY created_at') + .all(id) as { tag: string }[]).map((r) => r.tag); + + return { ...contact, emails, phones, links, handles, projects: projectsWithReporters, tags } as ContactDetail; }); ipcMain.handle('contacts:create', (_, data: CreateContactInput): ContactListItem => { @@ -333,7 +339,7 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw FROM contacts c WHERE c.id = ?`, ).get(id) as { id: string; name: string; organization: string | null; title: string | null; notes: string | null; created_at: number; has_email: 0|1; has_phone: 0|1; emails_raw: string|null; phones_raw: string|null }; - return { ...row, date_first_contacted: null, date_last_contacted: null, projects: [] }; + return { ...row, date_first_contacted: null, date_last_contacted: null, projects: [], tags: [] }; }); ipcMain.handle('contacts:delete', (_, id: string): void => { @@ -405,7 +411,7 @@ export function registerContactHandlers(): void { ipcMain.handle('contacts:list-for-project', (_, projectId: string): ProjectContactRow[] => { const db = getDatabase(); - return stmt(db, + const rows = stmt(db, `SELECT c.id, c.name, c.organization, c.notes, pm.id AS membership_id, pm.created_at AS membership_created_at, pm.reporter_name, pm.reporter_email, @@ -415,12 +421,14 @@ export function registerContactHandlers(): void { (SELECT GROUP_CONCAT(email, ' ') FROM contact_emails WHERE contact_id = c.id) AS emails_raw, (SELECT GROUP_CONCAT(phone, ' ') FROM contact_phones WHERE contact_id = c.id) AS phones_raw, (SELECT MIN(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_first_contacted, - (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted + (SELECT MAX(ile.created_at) FROM interaction_log_entries ile JOIN interaction_projects ip ON ip.interaction_id = ile.id WHERE ip.membership_id = pm.id) AS date_last_contacted, + (SELECT GROUP_CONCAT(tag, '\x1f') FROM (SELECT tag FROM contact_tags WHERE contact_id = c.id ORDER BY created_at)) AS tags_raw FROM project_memberships pm JOIN contacts c ON c.id = pm.contact_id WHERE pm.project_id = ? ORDER BY c.name COLLATE NOCASE ASC`, - ).all(projectId) as ProjectContactRow[]; + ).all(projectId) as Array & { tags_raw: string | null }>; + return rows.map((r) => ({ ...r, tags: r.tags_raw ? r.tags_raw.split('\x1f') : [] })); }); ipcMain.handle('contacts:update', (_, data: UpdateContactInput): void => { @@ -749,6 +757,34 @@ export function registerContactHandlers(): void { if (membershipIds.length > 0) broadcastRemindersChanged(); }); + ipcMain.handle('contacts:add-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { + const normalized = tag.trim().toLowerCase(); + if (!normalized || normalized.length > 50) return; + const db = getDatabase(); + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + const { changes } = db.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)') + .run(uuidv4(), contactId, normalized, now); + if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + })(); + broadcastContactsChanged(); + }); + + ipcMain.handle('contacts:remove-tag', (_, { contactId, tag }: { contactId: string; tag: string }): void => { + const normalized = tag.trim().toLowerCase(); + const db = getDatabase(); + const now = Math.floor(Date.now() / 1000); + db.transaction(() => { + const { changes } = db.prepare('DELETE FROM contact_tags WHERE contact_id = ? AND tag = ?').run(contactId, normalized); + if (changes > 0) db.prepare('UPDATE contacts SET updated_at = ? WHERE id = ?').run(now, contactId); + })(); + broadcastContactsChanged(); + }); + + ipcMain.handle('contacts:list-all-tags', (): string[] => { + return (getDatabase().prepare('SELECT DISTINCT tag FROM contact_tags ORDER BY tag').all() as { tag: string }[]).map((r) => r.tag); + }); + ipcMain.handle('contacts:count', (): number => { const row = getDatabase().prepare('SELECT COUNT(*) as n FROM contacts').get() as { n: number }; return row.n; diff --git a/src/main/sync/engine.ts b/src/main/sync/engine.ts index 80fa685..e65a767 100644 --- a/src/main/sync/engine.ts +++ b/src/main/sync/engine.ts @@ -392,6 +392,27 @@ function mergeSubTablesFromShared( ) .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); } + + // ── Tags ────────────────────────────────────────────────────────────────── + // Additive-only merge: deletions do not propagate across clients. A tag + // removed on one client will be restored on next sync if it still exists on + // shared. Full deletion propagation requires a tombstone table — see #422. + const sharedTags = shared + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + const localTags = local + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + + const sharedTagValues = new Set(sharedTags.map((t) => t.tag)); + const localOnlyTags = localTags.filter((t) => !sharedTagValues.has(t.tag)); + const mergedTags = [...sharedTags, ...localOnlyTags]; + + local.prepare('DELETE FROM contact_tags WHERE contact_id = ?').run(contactId); + const insertLocalTag = local.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)'); + for (const t of mergedTags) { + insertLocalTag.run(t.id, contactId, t.tag, t.created_at); + } } function pullMemberships( @@ -710,6 +731,19 @@ function pushSubTablesToShared( ) .run(rss.id, contactId, rss.rss_url, rss.last_polled_at, rss.is_invalid); } + + const sharedTagSet = new Set( + (shared.prepare('SELECT tag FROM contact_tags WHERE contact_id = ?').all(contactId) as { tag: string }[]).map((r) => r.tag), + ); + const tagRows = local + .prepare('SELECT * FROM contact_tags WHERE contact_id = ?') + .all(contactId) as { id: string; tag: string; created_at: number }[]; + const insertSharedTag = shared.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)'); + for (const t of tagRows) { + if (!sharedTagSet.has(t.tag)) { + insertSharedTag.run(t.id, contactId, t.tag, t.created_at); + } + } } function pushMemberships( diff --git a/src/preload/index.ts b/src/preload/index.ts index 1cd8269..eed3438 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -167,6 +167,11 @@ const sourcererApi = { getContactInteractionCount: (contactId: string): Promise => ipcRenderer.invoke('contacts:interaction-count', contactId), validatePhone: (raw: string): Promise => ipcRenderer.invoke('contacts:validate-phone', raw), + addContactTag: (contactId: string, tag: string): Promise => + ipcRenderer.invoke('contacts:add-tag', { contactId, tag }), + removeContactTag: (contactId: string, tag: string): Promise => + ipcRenderer.invoke('contacts:remove-tag', { contactId, tag }), + listAllContactTags: (): Promise => ipcRenderer.invoke('contacts:list-all-tags'), // Scratchpad listScratchpad: (contactId: string, projectId: string): Promise => diff --git a/src/renderer/src/contacts/GlobalTab.tsx b/src/renderer/src/contacts/GlobalTab.tsx index a28bf0c..8097aea 100644 --- a/src/renderer/src/contacts/GlobalTab.tsx +++ b/src/renderer/src/contacts/GlobalTab.tsx @@ -7,6 +7,7 @@ import ContactEditForm, { SOCIAL_TYPES, SOCIAL_META, KNOWN_LINK_TYPES } from './ import { isGoogleAlertUrl } from './contactValidation'; import RssAlertPanel from './RssAlertPanel'; import ScreenshotPanel from './ScreenshotPanel'; +import TagsSection from './TagsSection'; import { linkifyText } from '../utils/linkify'; import { HANDLE_TYPES, HANDLE_META } from './handleMeta'; import type { HandleType } from './handleMeta'; @@ -254,6 +255,12 @@ export default function GlobalTab({ contact, allProjects, onRefresh, onMembershi )} + + void; +} + +export default function TagsSection({ contactId, tags, onChanged }: Props) { + const [input, setInput] = useState(''); + const [allTags, setAllTags] = useState([]); + const [dropdownOpen, setDropdownOpen] = useState(false); + const inputRef = useRef(null); + const containerRef = useRef(null); + + useEffect(() => { + window.sourcerer.listAllContactTags().then(setAllTags); + }, [contactId]); + + useEffect(() => { + return window.sourcerer.onContactsChanged(() => { + window.sourcerer.listAllContactTags().then(setAllTags); + }); + }, []); + + useEffect(() => { + if (!dropdownOpen) return; + const onPointer = (e: PointerEvent) => { + if (containerRef.current && !containerRef.current.contains(e.target as Node)) { + setDropdownOpen(false); + } + }; + document.addEventListener('pointerdown', onPointer); + return () => document.removeEventListener('pointerdown', onPointer); + }, [dropdownOpen]); + + const trimmed = input.trim().toLowerCase(); + const suggestions = allTags + .filter((t) => !tags.includes(t) && (trimmed === '' || t.includes(trimmed))) + .slice(0, 10); + + async function addTag(tag: string) { + const normalized = tag.trim().toLowerCase(); + if (!normalized || normalized.length > 50 || tags.includes(normalized)) return; + await window.sourcerer.addContactTag(contactId, normalized); + setInput(''); + setDropdownOpen(false); + setAllTags((prev) => prev.includes(normalized) ? prev : [...prev, normalized].sort()); + onChanged(); + } + + async function removeTag(tag: string) { + await window.sourcerer.removeContactTag(contactId, tag); + onChanged(); + } + + function handleKeyDown(e: React.KeyboardEvent) { + if (e.key === 'Escape') { + e.stopPropagation(); + e.nativeEvent.stopImmediatePropagation(); + setDropdownOpen(false); + inputRef.current?.blur(); + } else if (e.key === 'Enter' || e.key === ',') { + e.preventDefault(); + if (trimmed) addTag(trimmed); + } else if (e.key === 'Backspace' && input === '' && tags.length > 0) { + removeTag(tags[tags.length - 1]); + } + } + + return ( +
+
Tags
+
+
+ {tags.map((t) => ( + + {t} + + + ))} + { + setInput(e.target.value.replace(/,/g, '')); + setDropdownOpen(true); + }} + onFocus={() => setDropdownOpen(true)} + onKeyDown={handleKeyDown} + maxLength={50} + /> +
+ {dropdownOpen && suggestions.length > 0 && ( +
    + {suggestions.map((t) => ( +
  • + +
  • + ))} +
+ )} +
+
+ ); +} diff --git a/src/renderer/src/env.d.ts b/src/renderer/src/env.d.ts index 88fd71d..eea9fa5 100644 --- a/src/renderer/src/env.d.ts +++ b/src/renderer/src/env.d.ts @@ -112,6 +112,9 @@ declare global { getContactCount: () => Promise; getContactInteractionCount: (contactId: string) => Promise; validatePhone: (raw: string) => Promise; + addContactTag: (contactId: string, tag: string) => Promise; + removeContactTag: (contactId: string, tag: string) => Promise; + listAllContactTags: () => Promise; // Scratchpad listScratchpad: (contactId: string, projectId: string) => Promise; diff --git a/src/renderer/src/hooks/useProjectContacts.ts b/src/renderer/src/hooks/useProjectContacts.ts index 9d37cbd..e2877e1 100644 --- a/src/renderer/src/hooks/useProjectContacts.ts +++ b/src/renderer/src/hooks/useProjectContacts.ts @@ -88,6 +88,9 @@ export function useProjectContacts( if (filters.reporter.length > 0) { result = result.filter((r) => filters.reporter.includes(r.reporter_name)); } + if (filters.tags.length > 0) { + result = result.filter((r) => filters.tags.some((t) => r.tags.includes(t))); + } if (sort.key) { const dir = sort.dir === 'asc' ? 1 : -1; diff --git a/src/renderer/src/views/AllContacts.css b/src/renderer/src/views/AllContacts.css index 39d52c1..d72273d 100644 --- a/src/renderer/src/views/AllContacts.css +++ b/src/renderer/src/views/AllContacts.css @@ -121,6 +121,31 @@ white-space: nowrap; } +.contact-tags-cell { + white-space: nowrap; +} + +.contact-tag-chip { + display: inline-block; + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.12em; + text-transform: uppercase; + background: var(--color-surface-2); + color: var(--color-text-muted); + padding: 1px 6px; + margin-right: 3px; + white-space: nowrap; +} + +.contact-tag-overflow { + font-family: var(--font-mono); + font-size: 10px; + letter-spacing: 0.1em; + color: var(--color-text-dim); + white-space: nowrap; +} + /* Compact boolean cells (email, phone, notes) */ .contact-bool-cell { text-align: center; diff --git a/src/renderer/src/views/AllContacts.tsx b/src/renderer/src/views/AllContacts.tsx index 8f61bc0..ca22b4c 100644 --- a/src/renderer/src/views/AllContacts.tsx +++ b/src/renderer/src/views/AllContacts.tsx @@ -154,6 +154,10 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta result = result.filter((c) => c.projects.some((p) => p.id === filters.project)); } + if (filters.tags.length > 0) { + result = result.filter((c) => filters.tags.some((t) => c.tags.includes(t))); + } + if (sort.key) { const dir = sort.dir === 'asc' ? 1 : -1; result = [...result].sort((a, b) => { @@ -272,6 +276,12 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta setDetailId(null); } + const tagFilterOptions = useMemo(() => { + const seen = new Set(); + for (const c of contacts) for (const t of c.tags) seen.add(t); + return [...seen].sort().map((t) => ({ value: t, label: t })); + }, [contacts]); + const anyFilter = isFilterActive(filters); return ( @@ -431,6 +441,7 @@ export default function AllContacts({ projects, user, openContactId, onOpenConta selectAllRef={selectAllRef} user={user} projects={projects} + tagFilterOptions={tagFilterOptions} virtualize scrollContainerRef={tableAreaRef} /> diff --git a/src/renderer/src/views/ColumnHeader.css b/src/renderer/src/views/ColumnHeader.css index f2a47f7..b368005 100644 --- a/src/renderer/src/views/ColumnHeader.css +++ b/src/renderer/src/views/ColumnHeader.css @@ -224,6 +224,26 @@ overflow-y: auto; } +.col-filter-search-wrap { + padding: 4px 0; +} + +.col-filter-search { + width: 100%; + border: 1px solid var(--color-border); + background: var(--color-bg); + font-family: var(--font-serif); + font-size: 13px; + color: var(--color-text); + padding: 3px 8px; + outline: none; + box-sizing: border-box; +} + +.col-filter-search::placeholder { + color: var(--color-text-dim); +} + .col-filter-clear-all { display: block; width: 100%; diff --git a/src/renderer/src/views/ColumnHeader.tsx b/src/renderer/src/views/ColumnHeader.tsx index ad1d62e..f3093ee 100644 --- a/src/renderer/src/views/ColumnHeader.tsx +++ b/src/renderer/src/views/ColumnHeader.tsx @@ -1,4 +1,4 @@ -import { useRef, useState, type ReactNode } from 'react'; +import { useEffect, useRef, useState, type ReactNode } from 'react'; import { createPortal } from 'react-dom'; import './ColumnHeader.css'; @@ -162,6 +162,9 @@ export function PresetFilter({ ); } +const MULTISELECT_SEARCH_THRESHOLD = 10; +const MULTISELECT_MAX_VISIBLE = 50; + export function MultiSelectFilter({ options, selected, @@ -171,10 +174,24 @@ export function MultiSelectFilter({ selected: string[]; onChange: (v: string[]) => void; }) { + const [search, setSearch] = useState(''); + const searchRef = useRef(null); + useEffect(() => { searchRef.current?.focus(); }, []); + function toggle(val: string) { onChange(selected.includes(val) ? selected.filter((v) => v !== val) : [...selected, val]); } + const showSearch = options.length > MULTISELECT_SEARCH_THRESHOLD; + const q = search.trim().toLowerCase(); + + const selectedSet = new Set(selected); + const selectedItems = options.filter((o) => selectedSet.has(o.value)); + const unselectedVisible = (q ? options.filter((o) => o.label.toLowerCase().includes(q)) : options) + .filter((o) => !selectedSet.has(o.value)) + .slice(0, Math.max(0, MULTISELECT_MAX_VISIBLE - selectedItems.length)); + const checkedFirst = [...selectedItems, ...unselectedVisible]; + return (
{selected.length > 0 && ( @@ -182,11 +199,23 @@ export function MultiSelectFilter({ Clear all )} - {options.map((opt) => ( + {showSearch && ( +
+ setSearch(e.target.value)} + /> +
+ )} + {checkedFirst.map((opt) => (