Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions src/main/database/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
9 changes: 9 additions & 0 deletions src/main/database/shared-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
48 changes: 42 additions & 6 deletions src/main/ipc/contacts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`,
)
Expand All @@ -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) => ({
Expand All @@ -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') : [],
}));
});

Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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 => {
Expand Down Expand Up @@ -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,
Expand All @@ -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<Omit<ProjectContactRow, 'tags'> & { 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 => {
Expand Down Expand Up @@ -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();
});
Comment on lines +773 to +782

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;
Expand Down
34 changes: 34 additions & 0 deletions src/main/sync/engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sync doesn't propagate tag deletions

The merge strategy here is purely additive: it takes sharedTags ∪ localOnlyTags and writes the union back to local. That means if a user on client A removes a tag and syncs, client B will never learn about the deletion — the tag stays on B forever.

The same additive-only pattern is used in pushSubTablesToShared (only inserts new tags, never deletes). This is consistent with how some other sub-tables are handled, but for an explicitly user-removable attribute like tags it's a meaningful limitation worth calling out in docs or comments.

If you want true delete propagation you'd need a tombstone/deletion-log table (hard). A middle ground is "shared wins on delete" — delete local tags not present in shared during merge — but that would silently remove legitimately local-only tags. At minimum this behaviour should be documented here so future developers don't assume deletions propagate.

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);
}
Comment on lines +413 to +415

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor: db.prepare() inside a loop re-parses the SQL on every iteration. Hoist it above the loop:

Suggested change
for (const t of mergedTags) {
local
.prepare('INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)')
.run(t.id, contactId, t.tag, t.created_at);
}
const insertTag = local.prepare(
'INSERT OR IGNORE INTO contact_tags (id, contact_id, tag, created_at) VALUES (?, ?, ?, ?)',
);
for (const t of mergedTags) {
insertTag.run(t.id, contactId, t.tag, t.created_at);
}

Same pattern applies in pushSubTablesToShared.

}

function pullMemberships(
Expand Down Expand Up @@ -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(
Expand Down
5 changes: 5 additions & 0 deletions src/preload/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ const sourcererApi = {
getContactInteractionCount: (contactId: string): Promise<number> =>
ipcRenderer.invoke('contacts:interaction-count', contactId),
validatePhone: (raw: string): Promise<boolean> => ipcRenderer.invoke('contacts:validate-phone', raw),
addContactTag: (contactId: string, tag: string): Promise<void> =>
ipcRenderer.invoke('contacts:add-tag', { contactId, tag }),
removeContactTag: (contactId: string, tag: string): Promise<void> =>
ipcRenderer.invoke('contacts:remove-tag', { contactId, tag }),
listAllContactTags: (): Promise<string[]> => ipcRenderer.invoke('contacts:list-all-tags'),

// Scratchpad
listScratchpad: (contactId: string, projectId: string): Promise<ScratchpadDraft[]> =>
Expand Down
7 changes: 7 additions & 0 deletions src/renderer/src/contacts/GlobalTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -254,6 +255,12 @@ export default function GlobalTab({ contact, allProjects, onRefresh, onMembershi
</div>
)}

<TagsSection
contactId={contact.id}
tags={contact.tags}
onChanged={onRefresh}
/>

<RssAlertPanel
editing={false}
alertRssList={alertRssList}
Expand Down
100 changes: 100 additions & 0 deletions src/renderer/src/contacts/TagsSection.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
.tags-editor {
position: relative;
}

.tags-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
align-items: center;
min-height: 28px;
}

.tags-chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 6px 2px 8px;
background: var(--color-surface-2);
border: 1px solid rgba(26, 24, 21, 0.14);
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-muted);
line-height: 1.5;
}

.tags-chip-remove {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing border-radius: 0 on a button element

Per the project design system (CLAUDE.md): "All interactive elements (buttons, inputs, selects, textareas) use border-radius: 0 — square corners everywhere, no exceptions." The .tags-chip-remove button needs an explicit reset here, even if the global stylesheet may default to 0, to be safe across browser agents and to follow the convention used elsewhere.

Suggested change
.tags-chip-remove {
.tags-chip-remove {
background: none;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-dim);
line-height: 1;
display: flex;
align-items: center;
}

background: none;
border: none;
border-radius: 0;
padding: 0;
margin: 0;
cursor: pointer;
font-family: var(--font-mono);
font-size: 12px;
color: var(--color-text-dim);
line-height: 1;
display: flex;
align-items: center;
}

.tags-chip-remove:hover {
color: var(--color-text);
}

.tags-input {
border: none;
background: transparent;
font-family: var(--font-mono);
font-size: 11px;
letter-spacing: 0.1em;
color: var(--color-text);
padding: 2px 4px 2px 0;
min-width: 80px;
flex: 1;
outline: none;
}

.tags-input::placeholder {
font-family: var(--font-serif);
font-size: 13px;
letter-spacing: 0;
color: var(--color-text-dim);
}

.tags-dropdown {
position: absolute;
top: calc(100% + 2px);
left: 0;
z-index: 100;
background: var(--color-surface);
border: 1px solid rgba(26, 24, 21, 0.28);
list-style: none;
margin: 0;
padding: 4px 0;
min-width: 160px;
max-height: 180px;
overflow-y: auto;
}

.tags-dropdown-item {
display: block;
width: 100%;
text-align: left;
background: none;
border: none;
padding: 5px 10px;
font-family: var(--font-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--color-text-muted);
cursor: pointer;
}

.tags-dropdown-item:hover {
background: var(--color-surface-2);
color: var(--color-text);
}
Loading
Loading