From 7282db064304daaa0ff234feea6013c426b42740 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 13:14:57 +0000 Subject: [PATCH 1/3] feat: add Contacts ontology, Address Book UI, and VCF import Introduce AddressBook and Contact as first-class Atomic resources with optional Agent linking, a query-backed Address Book page, and client-side vCard import for Google/iCloud/Microsoft exports. Co-authored-by: Joep Meindertsma --- .../BasicInstanceHandlers.ts | 30 ++ browser/data-browser/src/helpers/iconMap.ts | 4 + .../src/helpers/standardLocations.ts | 13 + .../src/routes/NewResource/BaseButtons.tsx | 2 + .../src/views/Card/ResourceCard.tsx | 6 + .../src/views/Contacts/AddressBookCard.tsx | 21 ++ .../src/views/Contacts/AddressBookPage.tsx | 202 +++++++++++ .../src/views/Contacts/ContactCard.tsx | 34 ++ .../src/views/Contacts/ContactPage.tsx | 195 +++++++++++ .../src/views/Contacts/ContactRow.tsx | 97 ++++++ .../src/views/Contacts/ImportVCardDialog.tsx | 213 ++++++++++++ .../src/views/Contacts/contactLocations.ts | 89 +++++ .../src/views/Contacts/importVCards.ts | 102 ++++++ .../src/views/Contacts/vcf.test.ts | 106 ++++++ .../data-browser/src/views/Contacts/vcf.ts | 315 ++++++++++++++++++ .../data-browser/src/views/ResourcePage.tsx | 6 + browser/lib/src/ontologies/dataBrowser.ts | 72 ++++ lib/defaults/contacts.json | 175 ++++++++++ lib/src/populate.rs | 7 + lib/src/urls.rs | 16 + planning/README.md | 3 +- planning/contacts.md | 264 +++++++++++++++ planning/personal-information-suite.md | 6 +- 23 files changed, 1974 insertions(+), 4 deletions(-) create mode 100644 browser/data-browser/src/views/Contacts/AddressBookCard.tsx create mode 100644 browser/data-browser/src/views/Contacts/AddressBookPage.tsx create mode 100644 browser/data-browser/src/views/Contacts/ContactCard.tsx create mode 100644 browser/data-browser/src/views/Contacts/ContactPage.tsx create mode 100644 browser/data-browser/src/views/Contacts/ContactRow.tsx create mode 100644 browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx create mode 100644 browser/data-browser/src/views/Contacts/contactLocations.ts create mode 100644 browser/data-browser/src/views/Contacts/importVCards.ts create mode 100644 browser/data-browser/src/views/Contacts/vcf.test.ts create mode 100644 browser/data-browser/src/views/Contacts/vcf.ts create mode 100644 lib/defaults/contacts.json create mode 100644 planning/contacts.md diff --git a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts index 1eb4543d1..8a264e23c 100644 --- a/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +++ b/browser/data-browser/src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts @@ -2,6 +2,10 @@ import { dataBrowser, core, classes, ai, canvas } from '@tomic/react'; import { registerBasicInstanceHandler } from '../useNewResourceUI'; import { DEFAULT_AICHAT_NAME } from '../../../AI/aiContstants'; import { getOrCreateMeetingsFolder } from '../../../../helpers/standardLocations'; +import { + resolveAddressBookParent, + resolveContactParent, +} from '../../../../views/Contacts/contactLocations'; /** * These handlers do not show any UI / inputs when creating new instances. @@ -85,6 +89,32 @@ export const registerBasicInstanceHandlers = () => { }, ); + registerBasicInstanceHandler( + dataBrowser.classes.addressBook, + async (parent, createAndNavigate, { store }) => { + const bookParent = await resolveAddressBookParent(store, parent); + + await createAndNavigate( + dataBrowser.classes.addressBook, + { [core.properties.name]: 'Address Book' }, + { parent: bookParent }, + ); + }, + ); + + registerBasicInstanceHandler( + dataBrowser.classes.contact, + async (parent, createAndNavigate, { store }) => { + const contactParent = await resolveContactParent(store, parent); + + await createAndNavigate( + dataBrowser.classes.contact, + { [core.properties.name]: 'Contact' }, + { parent: contactParent }, + ); + }, + ); + registerBasicInstanceHandler( canvas.classes.canvas, async (parent, createAndNavigate) => { diff --git a/browser/data-browser/src/helpers/iconMap.ts b/browser/data-browser/src/helpers/iconMap.ts index 4384b13e5..4d1dc08b5 100644 --- a/browser/data-browser/src/helpers/iconMap.ts +++ b/browser/data-browser/src/helpers/iconMap.ts @@ -40,6 +40,8 @@ import { FaCode, FaPuzzlePiece, FaVideo, + FaAddressBook, + FaUser, } from 'react-icons/fa6'; import { AIIcon } from '../components/AI/AIIcon'; @@ -50,6 +52,8 @@ const iconMap = new Map([ [dataBrowser.classes.document, FaFileLines], [dataBrowser.classes.documentV2, FaFileLines], [dataBrowser.classes.meeting, FaVideo], + [dataBrowser.classes.addressBook, FaAddressBook], + [dataBrowser.classes.contact, FaUser], [server.classes.file, FaFile], [server.classes.drive, FaHardDrive], [server.classes.plugin, FaPuzzlePiece], diff --git a/browser/data-browser/src/helpers/standardLocations.ts b/browser/data-browser/src/helpers/standardLocations.ts index d50a1397f..49d0e4d34 100644 --- a/browser/data-browser/src/helpers/standardLocations.ts +++ b/browser/data-browser/src/helpers/standardLocations.ts @@ -86,6 +86,19 @@ export async function getOrCreateMeetingsFolder( ); } +/** The Drive's Contacts folder: home for Address Book resources. */ +export async function getOrCreateContactsFolder( + store: Store, + driveSubject: string, +): Promise { + return getOrCreateDriveLocation( + store, + driveSubject, + dataBrowser.properties.contactsFolder, + { isA: dataBrowser.classes.folder, name: /* @wc-ignore */ 'Contacts' }, + ); +} + /** The Drive's follow-sessions ChatRoom: while an agent is being followed, * their client logs which resources they visit here (issue #1229). */ export async function getOrCreateFollowSessionsChatroom( diff --git a/browser/data-browser/src/routes/NewResource/BaseButtons.tsx b/browser/data-browser/src/routes/NewResource/BaseButtons.tsx index 00900b0e7..91d3c5754 100644 --- a/browser/data-browser/src/routes/NewResource/BaseButtons.tsx +++ b/browser/data-browser/src/routes/NewResource/BaseButtons.tsx @@ -15,6 +15,8 @@ const buttons = [ dataBrowser.classes.folder, dataBrowser.classes.documentV2, dataBrowser.classes.meeting, + dataBrowser.classes.addressBook, + dataBrowser.classes.contact, dataBrowser.classes.chatroom, dataBrowser.classes.bookmark, canvas.classes.canvas, diff --git a/browser/data-browser/src/views/Card/ResourceCard.tsx b/browser/data-browser/src/views/Card/ResourceCard.tsx index 3ed506c46..43b4b0a2e 100644 --- a/browser/data-browser/src/views/Card/ResourceCard.tsx +++ b/browser/data-browser/src/views/Card/ResourceCard.tsx @@ -36,6 +36,8 @@ import { HideInPrint } from '@components/HideInPrint'; import { useOnValueChange } from '@helpers/useOnValueChange'; import { FolderCard } from './FolderCard'; import { PluginCard } from '@views/Plugin/PluginCard'; +import { ContactCard } from '../Contacts/ContactCard'; +import { AddressBookCard } from '../Contacts/AddressBookCard'; interface ResourceCardProps extends CardViewPropsBase { /** The subject URL - the identifier of the resource. */ @@ -128,6 +130,10 @@ function ResourceCardInner(props: ResourceCardProps): JSX.Element { return ; case dataBrowser.classes.meeting: return ; + case dataBrowser.classes.contact: + return ; + case dataBrowser.classes.addressBook: + return ; case dataBrowser.classes.folder: return ; case server.classes.plugin: diff --git a/browser/data-browser/src/views/Contacts/AddressBookCard.tsx b/browser/data-browser/src/views/Contacts/AddressBookCard.tsx new file mode 100644 index 000000000..adf5e4b40 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/AddressBookCard.tsx @@ -0,0 +1,21 @@ +import type { JSX } from 'react'; +import { Column, Row } from '../../components/Row'; +import { ResourceCardTitle } from '../Card/ResourceCardTitle'; +import { ResourceContextMenu } from '../../components/ResourceContextMenu'; +import type { CardViewProps } from '../Card/CardViewProps'; +import { HideInPrint } from '../../components/HideInPrint'; + +export function AddressBookCard({ resource }: CardViewProps): JSX.Element { + return ( + + + + Address Book + + + + + + + ); +} diff --git a/browser/data-browser/src/views/Contacts/AddressBookPage.tsx b/browser/data-browser/src/views/Contacts/AddressBookPage.tsx new file mode 100644 index 000000000..26944a388 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/AddressBookPage.tsx @@ -0,0 +1,202 @@ +import { useState, type JSX } from 'react'; +import { styled } from 'styled-components'; +import { + core, + dataBrowser, + useCollection, + useMemberFromCollection, + useString, + useTitle, + type Collection, + type DataBrowser, +} from '@tomic/react'; +import { FaFileImport, FaPlus, FaMagnifyingGlass } from 'react-icons/fa6'; +import type { ResourcePageProps } from '../ResourcePage'; +import { EditableTitle } from '../../components/EditableTitle'; +import { ContainerWide } from '../../components/Containers'; +import { Column, Row } from '../../components/Row'; +import { Button } from '../../components/Button'; +import { SkeletonButton } from '../../components/SkeletonButton'; +import { InputStyled, InputWrapper } from '../../components/forms/InputStyles'; +import { useNewResourceUI } from '../../components/forms/NewForm/useNewResourceUI'; +import { LoaderInline } from '../../components/Loader'; +import { ContactRow } from './ContactRow'; +import { ImportVCardDialog } from './ImportVCardDialog'; + +export function AddressBookPage({ + resource, +}: ResourcePageProps): JSX.Element { + const [query, setQuery] = useState(''); + const [showImport, setShowImport] = useState(false); + const showNewResourceUI = useNewResourceUI(); + + const { collection, ready, invalidateCollection, mapAll } = useCollection( + { + property: core.properties.parent, + value: resource.subject, + filters: [ + { + property: core.properties.isA, + value: dataBrowser.classes.contact, + }, + ], + sort_by: core.properties.name, + }, + { pageSize: 200 }, + ); + + return ( + + +
+ + + + + +
+ + + + + + + setQuery(e.target.value)} + placeholder='Filter contacts…' + aria-label='Filter contacts' + /> + + + + {!ready && Loading contacts…} + + {ready && collection.totalMembers === 0 && ( + +

+ No contacts yet. Create one or import a `.vcf` export from Google, + iCloud, or Microsoft. +

+ + + showNewResourceUI( + dataBrowser.classes.contact, + resource.subject, + ) + } + > + New Contact + + setShowImport(true)}> + Import vCard + + +
+ )} + + {ready && collection.totalMembers > 0 && ( + + {mapAll(({ index, collection: col }) => ( + + ))} + + )} +
+ + setShowImport(false)} + onImported={() => { + void invalidateCollection(); + }} + /> +
+ ); +} + +function FilteredContactRow({ + collection, + index, + query, +}: { + collection: Collection; + index: number; + query: string; +}): JSX.Element | null { + const resource = useMemberFromCollection(collection, index); + const [title] = useTitle(resource); + const [email] = useString(resource, dataBrowser.properties.email); + const [telephone] = useString(resource, dataBrowser.properties.telephone); + const [organization] = useString( + resource, + dataBrowser.properties.organization, + ); + + if (resource.loading || !resource.subject) { + return null; + } + + const q = query.trim().toLowerCase(); + + if ( + q && + ![title, email, telephone, organization] + .filter(Boolean) + .some(v => v!.toLowerCase().includes(q)) + ) { + return null; + } + + return ; +} + +const Header = styled(Row)` + align-items: flex-start; + justify-content: space-between; + gap: ${p => p.theme.size(3)}; + flex-wrap: wrap; +`; + +const SearchRow = styled.div` + max-width: 28rem; +`; + +const SearchIcon = styled.span` + display: flex; + color: ${p => p.theme.colors.textLight}; +`; + +const List = styled.div` + border: 1px solid ${p => p.theme.colors.bg2}; + border-radius: ${p => p.theme.radius}; + overflow: hidden; + + > * + * { + border-top: 1px solid ${p => p.theme.colors.bg2}; + } +`; + +const Empty = styled(Column)` + align-items: flex-start; + gap: ${p => p.theme.size(3)}; + padding: ${p => p.theme.size(6)} 0; + color: ${p => p.theme.colors.textLight}; +`; diff --git a/browser/data-browser/src/views/Contacts/ContactCard.tsx b/browser/data-browser/src/views/Contacts/ContactCard.tsx new file mode 100644 index 000000000..cdfbf367e --- /dev/null +++ b/browser/data-browser/src/views/Contacts/ContactCard.tsx @@ -0,0 +1,34 @@ +import type { JSX } from 'react'; +import { dataBrowser, useString } from '@tomic/react'; +import { Column, Row } from '../../components/Row'; +import { ResourceCardTitle } from '../Card/ResourceCardTitle'; +import { ResourceContextMenu } from '../../components/ResourceContextMenu'; +import { ResourceInline } from '../ResourceInline'; +import type { CardViewProps } from '../Card/CardViewProps'; +import { HideInPrint } from '../../components/HideInPrint'; + +export function ContactCard({ resource }: CardViewProps): JSX.Element { + const [email] = useString(resource, dataBrowser.properties.email); + const [telephone] = useString(resource, dataBrowser.properties.telephone); + const [organization] = useString( + resource, + dataBrowser.properties.organization, + ); + const [agent] = useString(resource, dataBrowser.properties.contactAgent); + const meta = [organization, email, telephone].filter(Boolean).join(' · '); + + return ( + + + + Contact + + + + + + {meta &&
{meta}
} + {agent && } +
+ ); +} diff --git a/browser/data-browser/src/views/Contacts/ContactPage.tsx b/browser/data-browser/src/views/Contacts/ContactPage.tsx new file mode 100644 index 000000000..de98908c6 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/ContactPage.tsx @@ -0,0 +1,195 @@ +import type { JSX } from 'react'; +import { styled } from 'styled-components'; +import { + core, + dataBrowser, + useCanWrite, + useString, + type DataBrowser, +} from '@tomic/react'; +import { FaPencil } from 'react-icons/fa6'; +import type { ResourcePageProps } from '../ResourcePage'; +import { EditableTitle } from '../../components/EditableTitle'; +import { ContainerNarrow } from '../../components/Containers'; +import { Column, Row } from '../../components/Row'; +import { Button } from '../../components/Button'; +import AllProps from '../../components/AllProps'; +import { ValueForm } from '../../components/forms/ValueForm'; +import { ResourceInline } from '../ResourceInline'; +import { ResourceCoverImage } from '../../components/ResourceDecorations'; +import { defaultHiddenProps } from '../ResourcePageDefault'; +import { useNavigateWithTransition } from '../../hooks/useNavigateWithTransition'; +import { editURL } from '../../helpers/navigation'; +import { ResourceGlyph } from '../../components/ResourceGlyph'; + +const contactHiddenProps = [ + ...defaultHiddenProps, + dataBrowser.properties.givenName, + dataBrowser.properties.familyName, + dataBrowser.properties.organization, + dataBrowser.properties.jobTitle, + dataBrowser.properties.email, + dataBrowser.properties.telephone, + dataBrowser.properties.contactAgent, + dataBrowser.properties.website, + dataBrowser.properties.vcardUid, +]; + +export function ContactPage({ + resource, +}: ResourcePageProps): JSX.Element { + const canEdit = useCanWrite(resource); + const navigate = useNavigateWithTransition(); + const [email] = useString(resource, dataBrowser.properties.email); + const [telephone] = useString(resource, dataBrowser.properties.telephone); + const [organization] = useString( + resource, + dataBrowser.properties.organization, + ); + const [jobTitle] = useString(resource, dataBrowser.properties.jobTitle); + const [givenName] = useString(resource, dataBrowser.properties.givenName); + const [familyName] = useString(resource, dataBrowser.properties.familyName); + const [website] = useString(resource, dataBrowser.properties.website); + const [agent] = useString(resource, dataBrowser.properties.contactAgent); + + const subtitle = [jobTitle, organization].filter(Boolean).join(' · '); + const formalName = [givenName, familyName].filter(Boolean).join(' '); + + return ( + <> + + + +
+ + + + + + + {subtitle && {subtitle}} + {formalName && formalName !== resource.title && ( + {formalName} + )} + + + {canEdit && ( + + )} +
+ + + {email && ( + + + {email} + + )} + {telephone && ( + + + {telephone} + + )} + {website && ( + + + + {website} + + + )} + {agent && ( + + + + + )} + {!email && !telephone && !website && !agent && ( + No contact details yet — use Edit to add them. + )} + + + + + +
+
+ + ); +} + +const Header = styled(Row)` + align-items: flex-start; + justify-content: space-between; + gap: ${p => p.theme.size(3)}; +`; + +const Identity = styled(Row)` + align-items: center; + gap: ${p => p.theme.size(3)}; + min-width: 0; +`; + +const Avatar = styled.div` + display: flex; + align-items: center; + justify-content: center; + width: 3.25rem; + height: 3.25rem; + border-radius: 50%; + background: ${p => p.theme.colors.bg1}; + color: ${p => p.theme.colors.main}; + font-size: 1.5rem; + flex-shrink: 0; +`; + +const Subtitle = styled.div` + color: ${p => p.theme.colors.textLight}; + font-size: 0.95rem; +`; + +const Reachability = styled.div` + display: grid; + gap: ${p => p.theme.size(2)}; + padding: ${p => p.theme.size(3)}; + border: 1px solid ${p => p.theme.colors.bg2}; + border-radius: ${p => p.theme.radius}; +`; + +const Fact = styled.div` + display: grid; + grid-template-columns: 8rem 1fr; + gap: ${p => p.theme.size(2)}; + align-items: center; + + a { + color: ${p => p.theme.colors.main}; + overflow: hidden; + text-overflow: ellipsis; + } +`; + +const Label = styled.span` + color: ${p => p.theme.colors.textLight}; + font-size: 0.85rem; + font-weight: 600; +`; + +const Muted = styled.span` + color: ${p => p.theme.colors.textLight}; + font-size: 0.9rem; +`; diff --git a/browser/data-browser/src/views/Contacts/ContactRow.tsx b/browser/data-browser/src/views/Contacts/ContactRow.tsx new file mode 100644 index 000000000..7a09618aa --- /dev/null +++ b/browser/data-browser/src/views/Contacts/ContactRow.tsx @@ -0,0 +1,97 @@ +import type { JSX } from 'react'; +import { styled } from 'styled-components'; +import { + dataBrowser, + useResource, + useString, + useTitle, +} from '@tomic/react'; +import { AtomicLink } from '../../components/AtomicLink'; +import { ResourceInline } from '../ResourceInline'; +import { ResourceGlyph } from '../../components/ResourceGlyph'; + +type ContactRowProps = { + subject: string; +}; + +export function ContactRow({ subject }: ContactRowProps): JSX.Element { + const resource = useResource(subject); + const [title] = useTitle(resource); + const [email] = useString(resource, dataBrowser.properties.email); + const [telephone] = useString(resource, dataBrowser.properties.telephone); + const [organization] = useString( + resource, + dataBrowser.properties.organization, + ); + const [agent] = useString(resource, dataBrowser.properties.contactAgent); + + return ( + + + + +
+ {title} + {(organization || email || telephone) && ( + + {[organization, email, telephone].filter(Boolean).join(' · ')} + + )} +
+ {agent && ( + { + e.preventDefault(); + e.stopPropagation(); + }} + > + + + )} +
+ ); +} + +const RowLink = styled(AtomicLink)` + display: grid; + grid-template-columns: auto 1fr auto; + align-items: center; + gap: ${p => p.theme.size(3)}; + padding: ${p => p.theme.size(3)} ${p => p.theme.size(2)}; + border-radius: ${p => p.theme.radius}; + text-decoration: none; + color: inherit; + + &:hover { + background: ${p => p.theme.colors.bg1}; + } +`; + +const GlyphWrap = styled.div` + display: flex; + font-size: 1.25rem; + color: ${p => p.theme.colors.textLight}; +`; + +const Main = styled.div` + min-width: 0; +`; + +const Name = styled.div` + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const Meta = styled.div` + color: ${p => p.theme.colors.textLight}; + font-size: 0.85rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +`; + +const AgentWrap = styled.div` + font-size: 0.85rem; +`; diff --git a/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx b/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx new file mode 100644 index 000000000..40e8fc8b4 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx @@ -0,0 +1,213 @@ +import { useCallback, useEffect, useState, type FC, type JSX } from 'react'; +import { useDropzone } from 'react-dropzone'; +import toast from 'react-hot-toast'; +import { styled } from 'styled-components'; +import { useStore } from '@tomic/react'; +import { FaFileImport } from 'react-icons/fa6'; +import { + Dialog, + DialogActions, + DialogContent, + DialogTitle, + useDialog, +} from '../../components/Dialog'; +import { Button } from '../../components/Button'; +import { Column, Row } from '../../components/Row'; +import { parseVCardDocument, type ParsedVCard } from './vcf'; +import { importVCards } from './importVCards'; + +type ImportVCardDialogProps = { + addressBook: string; + show: boolean; + onClose: () => void; + onImported?: () => void; +}; + +export const ImportVCardDialog: FC = ({ + addressBook, + show: shouldShow, + onClose, + onImported, +}): JSX.Element => { + const store = useStore(); + const [cards, setCards] = useState([]); + const [fileName, setFileName] = useState(); + const [importing, setImporting] = useState(false); + const [error, setError] = useState(); + + const [dialogProps, show, hide, isOpen] = useDialog({ + onCancel: () => { + setCards([]); + setFileName(undefined); + setError(undefined); + onClose(); + }, + }); + + useEffect(() => { + if (shouldShow) { + show(); + } + }, [shouldShow, show]); + + const onDrop = useCallback(async (accepted: File[]) => { + const file = accepted[0]; + + if (!file) { + return; + } + + setError(undefined); + setFileName(file.name); + + try { + const text = await file.text(); + const parsed = parseVCardDocument(text); + + if (parsed.length === 0) { + setCards([]); + setError('No contacts found in this file.'); + return; + } + + setCards(parsed); + } catch (e) { + setCards([]); + setError(e instanceof Error ? e.message : 'Failed to read file'); + } + }, []); + + const { getRootProps, getInputProps, isDragActive } = useDropzone({ + onDrop, + accept: { + 'text/vcard': ['.vcf', '.vcard'], + 'text/x-vcard': ['.vcf', '.vcard'], + 'text/plain': ['.vcf', '.vcard'], + }, + multiple: false, + }); + + const handleImport = async () => { + setImporting(true); + + try { + const result = await importVCards(store, addressBook, cards); + toast.success( + `Imported ${result.created} contact${result.created === 1 ? '' : 's'}` + + (result.updated > 0 ? `, updated ${result.updated}` : ''), + ); + setCards([]); + setFileName(undefined); + hide(); + onClose(); + onImported?.(); + } catch (e) { + toast.error(e instanceof Error ? e.message : 'Import failed'); + } + + setImporting(false); + }; + + if (!isOpen) { + return <>; + } + + return ( + + +

Import vCard

+
+ + + + Export contacts from Google Contacts, iCloud, or Microsoft as a + `.vcf` file, then drop it here. + + + + + + {isDragActive + ? 'Drop vCard here…' + : fileName + ? fileName + : 'Drop a .vcf file, or click to browse'} + + + {error && {error}} + {cards.length > 0 && ( + + + {cards.length} contact{cards.length === 1 ? '' : 's'} ready + +
    + {cards.slice(0, 8).map((card, i) => ( +
  • + {card.name} + {card.email ? ` · ${card.email}` : ''} +
  • + ))} + {cards.length > 8 &&
  • …and {cards.length - 8} more
  • } +
+
+ )} +
+
+ + + + + + +
+ ); +}; + +const Hint = styled.p` + margin: 0; + color: ${p => p.theme.colors.textLight}; + font-size: 0.95rem; +`; + +const DropZone = styled.div<{ $active: boolean }>` + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: ${p => p.theme.size(2)}; + padding: ${p => p.theme.size(6)}; + border: 2px dashed + ${p => (p.$active ? p.theme.colors.main : p.theme.colors.bg2)}; + border-radius: ${p => p.theme.radius}; + background: ${p => (p.$active ? p.theme.colors.mainLight : p.theme.colors.bg)}; + color: ${p => p.theme.colors.textLight}; + cursor: pointer; + text-align: center; + + &:hover { + border-color: ${p => p.theme.colors.main}; + } +`; + +const Preview = styled.div` + font-size: 0.9rem; + + ul { + margin: ${p => p.theme.size(2)} 0 0; + padding-inline-start: 1.2rem; + color: ${p => p.theme.colors.textLight}; + } +`; + +const ErrorText = styled.p` + margin: 0; + color: ${p => p.theme.colors.alert}; +`; diff --git a/browser/data-browser/src/views/Contacts/contactLocations.ts b/browser/data-browser/src/views/Contacts/contactLocations.ts new file mode 100644 index 000000000..76dedcb9e --- /dev/null +++ b/browser/data-browser/src/views/Contacts/contactLocations.ts @@ -0,0 +1,89 @@ +import { CollectionBuilder, core, dataBrowser, type Store } from '@tomic/react'; +import { getOrCreateContactsFolder } from '../../helpers/standardLocations'; + +/** + * Default Address Book inside the Drive's Contacts folder. Created on first + * Contact create when the caller didn't pick an Address Book parent. + */ +export async function getOrCreateDefaultAddressBook( + store: Store, + driveSubject: string, +): Promise { + const contactsFolder = await getOrCreateContactsFolder(store, driveSubject); + + const existing = await new CollectionBuilder(store) + .setProperty(core.properties.parent) + .setValue(contactsFolder) + .addFilter({ + property: core.properties.isA, + value: dataBrowser.classes.addressBook, + }) + .setPageSize(1) + .buildAndFetch(); + + if (existing.totalMembers > 0) { + const subject = await existing.getMemberWithIndex(0); + + if (subject) { + return subject; + } + } + + const book = await store.newResource({ + parent: contactsFolder, + isA: dataBrowser.classes.addressBook, + propVals: { [core.properties.name]: /* @wc-ignore */ 'Contacts' }, + }); + await book.save(); + store.notifyResourceManuallyCreated(book); + + return book.subject; +} + +/** Parent to use when creating a Contact from an arbitrary location. */ +export async function resolveContactParent( + store: Store, + preferredParent?: string, +): Promise { + if (preferredParent) { + const parent = await store.getResource(preferredParent); + + if (!parent.error && parent.hasClasses(dataBrowser.classes.addressBook)) { + return preferredParent; + } + } + + const drive = store.getDrive(); + + if (!drive) { + if (preferredParent) { + return preferredParent; + } + + throw new Error('No drive available to place Contact'); + } + + return getOrCreateDefaultAddressBook(store, drive); +} + +/** Parent to use when creating an Address Book. */ +export async function resolveAddressBookParent( + store: Store, + preferredParent?: string, +): Promise { + const drive = store.getDrive(); + + if (!drive) { + if (preferredParent) { + return preferredParent; + } + + throw new Error('No drive available to place Address Book'); + } + + if (preferredParent && preferredParent !== drive) { + return preferredParent; + } + + return getOrCreateContactsFolder(store, drive); +} diff --git a/browser/data-browser/src/views/Contacts/importVCards.ts b/browser/data-browser/src/views/Contacts/importVCards.ts new file mode 100644 index 000000000..b3fa4ef13 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/importVCards.ts @@ -0,0 +1,102 @@ +import { core, dataBrowser, type JSONValue, type Store } from '@tomic/react'; +import { CollectionBuilder } from '@tomic/react'; +import type { ParsedVCard } from './vcf'; + +export type ImportVCardResult = { + created: number; + updated: number; +}; + +async function findContactByUid( + store: Store, + addressBook: string, + uid: string, +): Promise { + const collection = await new CollectionBuilder(store) + .setProperty(core.properties.parent) + .setValue(addressBook) + .setFilters([ + { + property: core.properties.isA, + value: dataBrowser.classes.contact, + }, + { + property: dataBrowser.properties.vcardUid, + value: uid, + }, + ]) + .setPageSize(1) + .buildAndFetch(); + + if (collection.totalMembers === 0) { + return undefined; + } + + return collection.getMemberWithIndex(0); +} + +function cardToPropVals(card: ParsedVCard): Record { + const props: Record = { + [core.properties.name]: card.name, + }; + + if (card.givenName) props[dataBrowser.properties.givenName] = card.givenName; + if (card.familyName) + props[dataBrowser.properties.familyName] = card.familyName; + if (card.organization) + props[dataBrowser.properties.organization] = card.organization; + if (card.jobTitle) props[dataBrowser.properties.jobTitle] = card.jobTitle; + if (card.email) props[dataBrowser.properties.email] = card.email; + if (card.telephone) props[dataBrowser.properties.telephone] = card.telephone; + if (card.emails.length > 0) + props[dataBrowser.properties.emails] = card.emails; + if (card.telephones.length > 0) + props[dataBrowser.properties.telephones] = card.telephones; + if (card.addresses.length > 0) + props[dataBrowser.properties.addresses] = card.addresses; + if (card.website) props[dataBrowser.properties.website] = card.website; + if (card.notes) props[core.properties.description] = card.notes; + if (card.uid) props[dataBrowser.properties.vcardUid] = card.uid; + + return props; +} + +/** Create or update Contact resources under an Address Book from parsed vCards. */ +export async function importVCards( + store: Store, + addressBook: string, + cards: ParsedVCard[], +): Promise { + let created = 0; + let updated = 0; + + for (const card of cards) { + const propVals = cardToPropVals(card); + const existingSubject = card.uid + ? await findContactByUid(store, addressBook, card.uid) + : undefined; + + if (existingSubject) { + const resource = await store.getResource(existingSubject); + + for (const [prop, value] of Object.entries(propVals)) { + await resource.set(prop, value); + } + + await resource.save(); + updated += 1; + continue; + } + + const resource = await store.newResource({ + parent: addressBook, + isA: dataBrowser.classes.contact, + propVals, + }); + await resource.save(); + store.notifyResourceManuallyCreated(resource); + created += 1; + } + + return { created, updated }; +} diff --git a/browser/data-browser/src/views/Contacts/vcf.test.ts b/browser/data-browser/src/views/Contacts/vcf.test.ts new file mode 100644 index 000000000..421e425b7 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/vcf.test.ts @@ -0,0 +1,106 @@ +import { describe, expect, it } from 'vitest'; +import { parseVCardDocument, unfoldVCard } from './vcf'; + +const googleStyle = `BEGIN:VCARD +VERSION:3.0 +N:Doe;Jane;;; +FN:Jane Doe +ORG:Acme Inc. +TITLE:Engineer +EMAIL;TYPE=INTERNET;TYPE=WORK:jane@acme.com +EMAIL;TYPE=INTERNET;TYPE=HOME:jane@example.com +TEL;TYPE=CELL:+1-555-0100 +TEL;TYPE=WORK:+1-555-0199 +ADR;TYPE=WORK:;;123 Main St;Springfield;IL;62701;USA +URL:https://example.com/jane +NOTE:Met at the conference. +UID:google-contact-uid-1 +END:VCARD +`; + +const appleFolded = `BEGIN:VCARD +VERSION:3.0 +N:Smith;Alex;;; +FN:Alex Smith +EMAIL;type=INTERNET;type=HOME;type=pref:alex@icloud.com +NOTE:Line one\\nLine two +ITEM1.URL;type=pref:https://example.com/a +UID:urn:uuid:apple-uid-42 +END:VCARD +`; + +const outlookMulti = `BEGIN:VCARD +VERSION:3.0 +FN:Bob Builder +N:Builder;Bob;;; +ORG:Build Co +TEL;WORK;VOICE:555-1000 +EMAIL;PREF;INTERNET:bob@build.co +END:VCARD +BEGIN:VCARD +VERSION:3.0 +FN:Carol +EMAIL:carol@example.org +END:VCARD +`; + +describe('unfoldVCard', () => { + it('joins folded lines', () => { + expect(unfoldVCard('NOTE:hello\r\n world')).toBe('NOTE:helloworld'); + }); +}); + +describe('parseVCardDocument', () => { + it('parses a Google-style contact', () => { + const [card] = parseVCardDocument(googleStyle); + expect(card).toMatchObject({ + name: 'Jane Doe', + givenName: 'Jane', + familyName: 'Doe', + organization: 'Acme Inc.', + jobTitle: 'Engineer', + email: 'jane@acme.com', + telephone: '+1-555-0100', + website: 'https://example.com/jane', + notes: 'Met at the conference.', + uid: 'google-contact-uid-1', + }); + expect(card.emails).toHaveLength(2); + expect(card.telephones).toHaveLength(2); + expect(card.addresses[0]).toMatchObject({ + street: '123 Main St', + locality: 'Springfield', + region: 'IL', + postalCode: '62701', + country: 'USA', + }); + }); + + it('parses an Apple-style contact with UID and note escapes', () => { + const [card] = parseVCardDocument(appleFolded); + expect(card.name).toBe('Alex Smith'); + expect(card.email).toBe('alex@icloud.com'); + expect(card.notes).toBe('Line one\nLine two'); + expect(card.uid).toBe('urn:uuid:apple-uid-42'); + expect(card.website).toBe('https://example.com/a'); + }); + + it('parses multiple Outlook-style cards', () => { + const cards = parseVCardDocument(outlookMulti); + expect(cards).toHaveLength(2); + expect(cards[0]).toMatchObject({ + name: 'Bob Builder', + organization: 'Build Co', + email: 'bob@build.co', + telephone: '555-1000', + }); + expect(cards[1].name).toBe('Carol'); + expect(cards[1].email).toBe('carol@example.org'); + }); + + it('skips empty cards', () => { + expect( + parseVCardDocument('BEGIN:VCARD\nVERSION:3.0\nEND:VCARD'), + ).toHaveLength(0); + }); +}); diff --git a/browser/data-browser/src/views/Contacts/vcf.ts b/browser/data-browser/src/views/Contacts/vcf.ts new file mode 100644 index 000000000..f03b4bdd5 --- /dev/null +++ b/browser/data-browser/src/views/Contacts/vcf.ts @@ -0,0 +1,315 @@ +/** + * First-party vCard (VCF) parser for Google Contacts / iCloud / Microsoft + * exports. Handles vCard 3.0 and the common 4.0 subset used by those apps. + */ + +export type VCardTypedValue = { + value: string; + type?: string; +}; + +export type VCardAddress = { + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + type?: string; +}; + +/** Normalized contact fields mapped from one BEGIN:VCARD … END:VCARD block. */ +export type ParsedVCard = { + name: string; + givenName?: string; + familyName?: string; + organization?: string; + jobTitle?: string; + email?: string; + telephone?: string; + emails: VCardTypedValue[]; + telephones: VCardTypedValue[]; + addresses: VCardAddress[]; + website?: string; + notes?: string; + uid?: string; +}; + +/** Unfold RFC 6350 line continuations (CRLF/LF + space or tab). */ +export function unfoldVCard(text: string): string { + return text.replace(/\r\n[\t ]|\n[\t ]|\r[\t ]/g, ''); +} + +function splitLines(text: string): string[] { + return unfoldVCard(text) + .replace(/\r\n/g, '\n') + .replace(/\r/g, '\n') + .split('\n') + .map(line => line.trimEnd()) + .filter(line => line.length > 0); +} + +type ParsedLine = { + name: string; + params: Record; + value: string; +}; + +/** Parse one content line into name / params / value (quoted-printable light). */ +function parseLine(line: string): ParsedLine | undefined { + const colon = line.indexOf(':'); + + if (colon === -1) { + return undefined; + } + + const meta = line.slice(0, colon); + let value = line.slice(colon + 1); + const parts = meta.split(';'); + const name = (parts[0] ?? '').split('.').pop()?.toUpperCase() ?? ''; + const params: Record = {}; + + for (const part of parts.slice(1)) { + const eq = part.indexOf('='); + + if (eq === -1) { + // Bare TYPE-like tokens: `;WORK;VOICE` + const key = part.toUpperCase(); + + if (key) { + params.TYPE = params.TYPE ? `${params.TYPE},${key}` : key; + } + + continue; + } + + const key = part.slice(0, eq).toUpperCase(); + const val = part.slice(eq + 1).replace(/^"|"$/g, ''); + params[key] = params[key] ? `${params[key]},${val}` : val; + } + + if (params.ENCODING?.toUpperCase() === 'QUOTED-PRINTABLE') { + value = decodeQuotedPrintable(value); + } + + value = unescapeVCardValue(value); + + return { name, params, value }; +} + +function unescapeVCardValue(value: string): string { + return value + .replace(/\\n/gi, '\n') + .replace(/\\,/g, ',') + .replace(/\\;/g, ';') + .replace(/\\\\/g, '\\'); +} + +function decodeQuotedPrintable(value: string): string { + return value + .replace(/=\r?\n/g, '') + .replace(/=([0-9A-Fa-f]{2})/g, (_, hex: string) => + String.fromCharCode(parseInt(hex, 16)), + ); +} + +function firstType(params: Record): string | undefined { + const raw = params.TYPE ?? params.TYPE; + + if (!raw) { + return undefined; + } + + return raw.split(',')[0]?.toLowerCase(); +} + +function splitStructured(value: string): string[] { + const parts: string[] = []; + let current = ''; + let escaped = false; + + for (const ch of value) { + if (escaped) { + current += ch; + escaped = false; + continue; + } + + if (ch === '\\') { + escaped = true; + continue; + } + + if (ch === ';') { + parts.push(current); + current = ''; + continue; + } + + current += ch; + } + + parts.push(current); + + return parts; +} + +function parseOneCard(lines: string[]): ParsedVCard | undefined { + const emails: VCardTypedValue[] = []; + const telephones: VCardTypedValue[] = []; + const addresses: VCardAddress[] = []; + let fn: string | undefined; + let givenName: string | undefined; + let familyName: string | undefined; + let organization: string | undefined; + let jobTitle: string | undefined; + let website: string | undefined; + let notes: string | undefined; + let uid: string | undefined; + + for (const line of lines) { + const parsed = parseLine(line); + + if (!parsed) { + continue; + } + + const { name, params, value } = parsed; + + switch (name) { + case 'FN': + fn = value.trim(); + break; + case 'N': { + const [family, given] = splitStructured(value); + familyName = family?.trim() || undefined; + givenName = given?.trim() || undefined; + break; + } + case 'ORG': + organization = splitStructured(value)[0]?.trim() || undefined; + break; + case 'TITLE': + jobTitle = value.trim() || undefined; + break; + case 'EMAIL': { + const email = value.trim(); + + if (email) { + emails.push({ value: email, type: firstType(params) }); + } + + break; + } + case 'TEL': { + const tel = value.replace(/^tel:/i, '').trim(); + + if (tel) { + telephones.push({ value: tel, type: firstType(params) }); + } + + break; + } + case 'ADR': { + const parts = splitStructured(value); + // ADR: PO Box; Extended; Street; Locality; Region; PostalCode; Country + const street = [parts[1], parts[2]].filter(Boolean).join('\n').trim(); + const address: VCardAddress = { + street: street || undefined, + locality: parts[3]?.trim() || undefined, + region: parts[4]?.trim() || undefined, + postalCode: parts[5]?.trim() || undefined, + country: parts[6]?.trim() || undefined, + type: firstType(params), + }; + + if ( + address.street || + address.locality || + address.region || + address.postalCode || + address.country + ) { + addresses.push(address); + } + + break; + } + case 'URL': + if (!website && value.trim()) { + website = value.trim(); + } + + break; + case 'NOTE': + notes = value.trim() || undefined; + break; + case 'UID': + uid = value.trim() || undefined; + break; + default: + break; + } + } + + const name = + fn || + [givenName, familyName].filter(Boolean).join(' ') || + organization || + emails[0]?.value || + telephones[0]?.value; + + if (!name) { + return undefined; + } + + return { + name, + givenName, + familyName, + organization, + jobTitle, + email: emails[0]?.value, + telephone: telephones[0]?.value, + emails, + telephones, + addresses, + website, + notes, + uid, + }; +} + +/** Parse a full `.vcf` document into zero or more contacts. */ +export function parseVCardDocument(text: string): ParsedVCard[] { + const lines = splitLines(text); + const cards: ParsedVCard[] = []; + let current: string[] | undefined; + + for (const line of lines) { + const upper = line.toUpperCase(); + + if (upper === 'BEGIN:VCARD') { + current = []; + continue; + } + + if (upper === 'END:VCARD') { + if (current) { + const card = parseOneCard(current); + + if (card) { + cards.push(card); + } + } + + current = undefined; + continue; + } + + if (current) { + current.push(line); + } + } + + return cards; +} diff --git a/browser/data-browser/src/views/ResourcePage.tsx b/browser/data-browser/src/views/ResourcePage.tsx index b6d764e42..8dfa71a6f 100644 --- a/browser/data-browser/src/views/ResourcePage.tsx +++ b/browser/data-browser/src/views/ResourcePage.tsx @@ -41,6 +41,8 @@ import { PluginPage } from '@views/Plugin/PluginPage'; import { useCustomViews } from '@components/CustomViewProvider'; import { PluginView } from './PluginView/PluginView'; import { MeetingPage } from './Meeting/MeetingPage'; +import { AddressBookPage } from './Contacts/AddressBookPage'; +import { ContactPage } from './Contacts/ContactPage'; const TablePage = lazy(() => import('../chunks/TablePage').then(m => ({ default: m.TablePage })), @@ -228,6 +230,10 @@ function selectComponent(klass: string | undefined) { return DocumentV2FullPage; case dataBrowser.classes.meeting: return MeetingPage; + case dataBrowser.classes.addressBook: + return AddressBookPage; + case dataBrowser.classes.contact: + return ContactPage; case canvas.classes.canvas: return CanvasPage; case server.classes.plugin: diff --git a/browser/lib/src/ontologies/dataBrowser.ts b/browser/lib/src/ontologies/dataBrowser.ts index e552b7c78..e9c630ec9 100644 --- a/browser/lib/src/ontologies/dataBrowser.ts +++ b/browser/lib/src/ontologies/dataBrowser.ts @@ -8,8 +8,10 @@ import type { OntologyBaseObject, BaseProps } from '../index.js'; export const dataBrowser = { classes: { article: 'https://atomicdata.dev/classes/Article', + addressBook: 'https://atomicdata.dev/classes/AddressBook', bookmark: 'https://atomicdata.dev/class/Bookmark', chatroom: 'https://atomicdata.dev/classes/ChatRoom', + contact: 'https://atomicdata.dev/classes/Contact', currencyProperty: 'https://atomicdata.dev/ontology/data-browser/class/currency-property', dateFormat: 'https://atomicdata.dev/classes/DateFormat', @@ -38,17 +40,30 @@ export const dataBrowser = { }, properties: { about: 'https://atomicdata.dev/properties/about', + addresses: 'https://atomicdata.dev/properties/addresses', color: 'https://atomicdata.dev/properties/color', commentsFolder: 'https://atomicdata.dev/properties/commentsFolder', + contactAgent: 'https://atomicdata.dev/properties/contactAgent', + contactsFolder: 'https://atomicdata.dev/properties/contactsFolder', coverImage: 'https://atomicdata.dev/properties/coverImage', coverImageFocus: 'https://atomicdata.dev/properties/coverImageFocus', + email: 'https://atomicdata.dev/properties/email', + emails: 'https://atomicdata.dev/properties/emails', + familyName: 'https://atomicdata.dev/properties/familyName', followSessionsChatroom: 'https://atomicdata.dev/properties/followSessionsChatroom', + givenName: 'https://atomicdata.dev/properties/givenName', + jobTitle: 'https://atomicdata.dev/properties/jobTitle', currentMeetings: 'https://atomicdata.dev/properties/currentMeetings', meetingsFolder: 'https://atomicdata.dev/properties/meetingsFolder', meetingStartedAt: 'https://atomicdata.dev/properties/meetingStartedAt', meetingEndedAt: 'https://atomicdata.dev/properties/meetingEndedAt', meetingLeader: 'https://atomicdata.dev/properties/meetingLeader', + organization: 'https://atomicdata.dev/properties/organization', + telephone: 'https://atomicdata.dev/properties/telephone', + telephones: 'https://atomicdata.dev/properties/telephones', + vcardUid: 'https://atomicdata.dev/properties/vcardUid', + website: 'https://atomicdata.dev/properties/website', currency: 'https://atomicdata.dev/ontology/data-browser/property/currency', customNodePositioning: 'https://atomicdata.dev/properties/custom-node-positioning', @@ -246,9 +261,11 @@ export const dataBrowser = { // eslint-disable-next-line @typescript-eslint/no-namespace export namespace DataBrowser { + export type AddressBook = typeof dataBrowser.classes.addressBook; export type Article = typeof dataBrowser.classes.article; export type Bookmark = typeof dataBrowser.classes.bookmark; export type Chatroom = typeof dataBrowser.classes.chatroom; + export type Contact = typeof dataBrowser.classes.contact; export type CurrencyProperty = typeof dataBrowser.classes.currencyProperty; export type DateFormat = typeof dataBrowser.classes.dateFormat; export type DisplayStyle = typeof dataBrowser.classes.displayStyle; @@ -275,6 +292,10 @@ export namespace DataBrowser { declare module '../index.js' { interface Classes { + [dataBrowser.classes.addressBook]: { + requires: BaseProps | 'https://atomicdata.dev/properties/name'; + recommends: 'https://atomicdata.dev/properties/description'; + }; [dataBrowser.classes.article]: { requires: | BaseProps @@ -298,6 +319,24 @@ declare module '../index.js' { requires: BaseProps | 'https://atomicdata.dev/properties/name'; recommends: typeof dataBrowser.properties.messages; }; + [dataBrowser.classes.contact]: { + requires: BaseProps | 'https://atomicdata.dev/properties/name'; + recommends: + | typeof dataBrowser.properties.givenName + | typeof dataBrowser.properties.familyName + | typeof dataBrowser.properties.organization + | typeof dataBrowser.properties.jobTitle + | typeof dataBrowser.properties.email + | typeof dataBrowser.properties.telephone + | typeof dataBrowser.properties.emails + | typeof dataBrowser.properties.telephones + | typeof dataBrowser.properties.addresses + | typeof dataBrowser.properties.website + | 'https://atomicdata.dev/properties/description' + | typeof dataBrowser.properties.image + | typeof dataBrowser.properties.contactAgent + | typeof dataBrowser.properties.vcardUid; + }; [dataBrowser.classes.currencyProperty]: { requires: BaseProps | typeof dataBrowser.properties.currency; recommends: never; @@ -466,16 +505,36 @@ declare module '../index.js' { interface PropTypeMapping { [dataBrowser.properties.about]: string; + [dataBrowser.properties.addresses]: Array<{ + street?: string; + locality?: string; + region?: string; + postalCode?: string; + country?: string; + type?: string; + }>; [dataBrowser.properties.color]: string; [dataBrowser.properties.commentsFolder]: string; + [dataBrowser.properties.contactAgent]: string; + [dataBrowser.properties.contactsFolder]: string; [dataBrowser.properties.coverImage]: string; [dataBrowser.properties.coverImageFocus]: number; + [dataBrowser.properties.email]: string; + [dataBrowser.properties.emails]: Array<{ value: string; type?: string }>; + [dataBrowser.properties.familyName]: string; [dataBrowser.properties.followSessionsChatroom]: string; + [dataBrowser.properties.givenName]: string; + [dataBrowser.properties.jobTitle]: string; [dataBrowser.properties.currentMeetings]: string[]; [dataBrowser.properties.meetingsFolder]: string; [dataBrowser.properties.meetingStartedAt]: number; [dataBrowser.properties.meetingEndedAt]: number; [dataBrowser.properties.meetingLeader]: string; + [dataBrowser.properties.organization]: string; + [dataBrowser.properties.telephone]: string; + [dataBrowser.properties.telephones]: Array<{ value: string; type?: string }>; + [dataBrowser.properties.vcardUid]: string; + [dataBrowser.properties.website]: string; [dataBrowser.properties.currency]: string; [dataBrowser.properties.customNodePositioning]: Record< string, @@ -587,16 +646,29 @@ declare module '../index.js' { interface PropSubjectToNameMapping { [dataBrowser.properties.about]: 'about'; + [dataBrowser.properties.addresses]: 'addresses'; [dataBrowser.properties.color]: 'color'; [dataBrowser.properties.commentsFolder]: 'commentsFolder'; + [dataBrowser.properties.contactAgent]: 'contactAgent'; + [dataBrowser.properties.contactsFolder]: 'contactsFolder'; [dataBrowser.properties.coverImage]: 'coverImage'; [dataBrowser.properties.coverImageFocus]: 'coverImageFocus'; + [dataBrowser.properties.email]: 'email'; + [dataBrowser.properties.emails]: 'emails'; + [dataBrowser.properties.familyName]: 'familyName'; [dataBrowser.properties.followSessionsChatroom]: 'followSessionsChatroom'; + [dataBrowser.properties.givenName]: 'givenName'; + [dataBrowser.properties.jobTitle]: 'jobTitle'; [dataBrowser.properties.currentMeetings]: 'currentMeetings'; [dataBrowser.properties.meetingsFolder]: 'meetingsFolder'; [dataBrowser.properties.meetingStartedAt]: 'meetingStartedAt'; [dataBrowser.properties.meetingEndedAt]: 'meetingEndedAt'; [dataBrowser.properties.meetingLeader]: 'meetingLeader'; + [dataBrowser.properties.organization]: 'organization'; + [dataBrowser.properties.telephone]: 'telephone'; + [dataBrowser.properties.telephones]: 'telephones'; + [dataBrowser.properties.vcardUid]: 'vcardUid'; + [dataBrowser.properties.website]: 'website'; [dataBrowser.properties.currency]: 'currency'; [dataBrowser.properties.customNodePositioning]: 'customNodePositioning'; [dataBrowser.properties.dateFormat]: 'dateFormat'; diff --git a/lib/defaults/contacts.json b/lib/defaults/contacts.json new file mode 100644 index 000000000..71eef8b24 --- /dev/null +++ b/lib/defaults/contacts.json @@ -0,0 +1,175 @@ +[ + { + "@id": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/isA": [ + "https://atomicdata.dev/class/ontology" + ], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology", + "https://atomicdata.dev/properties/shortname": "contacts", + "https://atomicdata.dev/properties/description": "Address books and contacts. A Contact is an address-book entry (name, emails, phones) owned by a Drive. It is not an Agent: Agents are cryptographic actors that sign commits. Optionally link a Contact to an Agent with `contactAgent`.", + "https://atomicdata.dev/properties/classes": [ + "https://atomicdata.dev/classes/AddressBook", + "https://atomicdata.dev/classes/Contact" + ], + "https://atomicdata.dev/properties/properties": [ + "https://atomicdata.dev/properties/contactsFolder", + "https://atomicdata.dev/properties/givenName", + "https://atomicdata.dev/properties/familyName", + "https://atomicdata.dev/properties/organization", + "https://atomicdata.dev/properties/jobTitle", + "https://atomicdata.dev/properties/email", + "https://atomicdata.dev/properties/telephone", + "https://atomicdata.dev/properties/emails", + "https://atomicdata.dev/properties/telephones", + "https://atomicdata.dev/properties/addresses", + "https://atomicdata.dev/properties/website", + "https://atomicdata.dev/properties/contactAgent", + "https://atomicdata.dev/properties/vcardUid" + ] + }, + { + "@id": "https://atomicdata.dev/properties/contactsFolder", + "https://atomicdata.dev/properties/classtype": "https://atomicdata.dev/classes/Folder", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/atomicURL", + "https://atomicdata.dev/properties/description": "Points to the Folder in this Drive where Address Books are stored.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "contacts-folder" + }, + { + "@id": "https://atomicdata.dev/properties/givenName", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Given / first name of a Contact (vCard N given).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "given-name" + }, + { + "@id": "https://atomicdata.dev/properties/familyName", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Family / last name of a Contact (vCard N family).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "family-name" + }, + { + "@id": "https://atomicdata.dev/properties/organization", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Organization or company associated with a Contact (vCard ORG).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "organization" + }, + { + "@id": "https://atomicdata.dev/properties/jobTitle", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Job title or role of a Contact (vCard TITLE).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "job-title" + }, + { + "@id": "https://atomicdata.dev/properties/email", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Primary email address of a Contact. Prefer this for search and Collection filters; full typed lists live in `emails`.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "email" + }, + { + "@id": "https://atomicdata.dev/properties/telephone", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Primary telephone number of a Contact. Prefer this for search and Collection filters; full typed lists live in `telephones`.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "telephone" + }, + { + "@id": "https://atomicdata.dev/properties/emails", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/json", + "https://atomicdata.dev/properties/description": "All email addresses for a Contact as a JSON array of `{ \"value\": string, \"type\"?: string }` objects (vCard EMAIL).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "emails" + }, + { + "@id": "https://atomicdata.dev/properties/telephones", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/json", + "https://atomicdata.dev/properties/description": "All telephone numbers for a Contact as a JSON array of `{ \"value\": string, \"type\"?: string }` objects (vCard TEL).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "telephones" + }, + { + "@id": "https://atomicdata.dev/properties/addresses", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/json", + "https://atomicdata.dev/properties/description": "Postal addresses for a Contact as a JSON array of objects with optional `street`, `locality`, `region`, `postalCode`, `country`, and `type` fields (vCard ADR).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "addresses" + }, + { + "@id": "https://atomicdata.dev/properties/website", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/uri", + "https://atomicdata.dev/properties/description": "Primary website or homepage of a Contact (vCard URL).", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "website" + }, + { + "@id": "https://atomicdata.dev/properties/contactAgent", + "https://atomicdata.dev/properties/classtype": "https://atomicdata.dev/classes/Agent", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/atomicURL", + "https://atomicdata.dev/properties/description": "Optional Atomic Agent linked to this Contact. The Agent is the cryptographic identity (`did:ad:agent:…`); the Contact remains a separate address-book resource. Linking does not grant the Agent rights on the Contact.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "contact-agent" + }, + { + "@id": "https://atomicdata.dev/properties/vcardUid", + "https://atomicdata.dev/properties/datatype": "https://atomicdata.dev/datatypes/string", + "https://atomicdata.dev/properties/description": "Stable vCard UID used to deduplicate Contacts on re-import from Google Contacts, iCloud, Microsoft, or other CardDAV exports.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Property"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/shortname": "vcard-uid" + }, + { + "@id": "https://atomicdata.dev/classes/AddressBook", + "https://atomicdata.dev/properties/description": "A container for Contact resources. Contacts are child resources (parent = this Address Book) and are listed with Collection queries.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Class"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/recommends": [ + "https://atomicdata.dev/properties/description" + ], + "https://atomicdata.dev/properties/requires": [ + "https://atomicdata.dev/properties/name" + ], + "https://atomicdata.dev/properties/shortname": "address-book" + }, + { + "@id": "https://atomicdata.dev/classes/Contact", + "https://atomicdata.dev/properties/description": "An address-book entry for a person or organization. Distinct from Agent: a Contact holds contact details you maintain; an Agent is a cryptographic actor. Optionally link them with `contactAgent`.", + "https://atomicdata.dev/properties/isA": ["https://atomicdata.dev/classes/Class"], + "https://atomicdata.dev/properties/parent": "https://atomicdata.dev/ontology/contacts", + "https://atomicdata.dev/properties/recommends": [ + "https://atomicdata.dev/properties/givenName", + "https://atomicdata.dev/properties/familyName", + "https://atomicdata.dev/properties/organization", + "https://atomicdata.dev/properties/jobTitle", + "https://atomicdata.dev/properties/email", + "https://atomicdata.dev/properties/telephone", + "https://atomicdata.dev/properties/emails", + "https://atomicdata.dev/properties/telephones", + "https://atomicdata.dev/properties/addresses", + "https://atomicdata.dev/properties/website", + "https://atomicdata.dev/properties/description", + "https://atomicdata.dev/ontology/data-browser/property/image", + "https://atomicdata.dev/properties/contactAgent", + "https://atomicdata.dev/properties/vcardUid" + ], + "https://atomicdata.dev/properties/requires": [ + "https://atomicdata.dev/properties/name" + ], + "https://atomicdata.dev/properties/shortname": "contact" + } +] diff --git a/lib/src/populate.rs b/lib/src/populate.rs index bc830ca6a..6704c0afb 100644 --- a/lib/src/populate.rs +++ b/lib/src/populate.rs @@ -330,6 +330,13 @@ pub async fn populate_default_store(store: &impl Storelike) -> AtomicResult<()> .import(include_str!("../defaults/i18n.json"), &ParseOpts::default()) .await .map_err(|e| format!("Failed to import i18n.json: {e}"))?; + store + .import( + include_str!("../defaults/contacts.json"), + &ParseOpts::default(), + ) + .await + .map_err(|e| format!("Failed to import contacts.json: {e}"))?; Ok(()) } diff --git a/lib/src/urls.rs b/lib/src/urls.rs index 0bbb67f43..7599409ed 100644 --- a/lib/src/urls.rs +++ b/lib/src/urls.rs @@ -195,8 +195,24 @@ pub const NEXT_PAGE: &str = "https://atomicdata.dev/properties/nextPage"; pub const ABOUT: &str = "https://atomicdata.dev/properties/about"; pub const COMMENTS_FOLDER: &str = "https://atomicdata.dev/properties/commentsFolder"; pub const MEETINGS_FOLDER: &str = "https://atomicdata.dev/properties/meetingsFolder"; +pub const CONTACTS_FOLDER: &str = "https://atomicdata.dev/properties/contactsFolder"; pub const FOLLOW_SESSIONS_CHATROOM: &str = "https://atomicdata.dev/properties/followSessionsChatroom"; +// ... for Contacts +pub const ADDRESS_BOOK: &str = "https://atomicdata.dev/classes/AddressBook"; +pub const CONTACT: &str = "https://atomicdata.dev/classes/Contact"; +pub const GIVEN_NAME: &str = "https://atomicdata.dev/properties/givenName"; +pub const FAMILY_NAME: &str = "https://atomicdata.dev/properties/familyName"; +pub const ORGANIZATION: &str = "https://atomicdata.dev/properties/organization"; +pub const JOB_TITLE: &str = "https://atomicdata.dev/properties/jobTitle"; +pub const EMAIL: &str = "https://atomicdata.dev/properties/email"; +pub const TELEPHONE: &str = "https://atomicdata.dev/properties/telephone"; +pub const EMAILS: &str = "https://atomicdata.dev/properties/emails"; +pub const TELEPHONES: &str = "https://atomicdata.dev/properties/telephones"; +pub const ADDRESSES: &str = "https://atomicdata.dev/properties/addresses"; +pub const WEBSITE: &str = "https://atomicdata.dev/properties/website"; +pub const CONTACT_AGENT: &str = "https://atomicdata.dev/properties/contactAgent"; +pub const VCARD_UID: &str = "https://atomicdata.dev/properties/vcardUid"; // ... for DocumentV2 pub const DOCUMENT_CONTENT: &str = "https://atomicdata.dev/properties/documentContent"; // ... for Importers diff --git a/planning/README.md b/planning/README.md index 04fea2742..6a0dedd71 100644 --- a/planning/README.md +++ b/planning/README.md @@ -30,7 +30,8 @@ scratch document. When a plan becomes obsolete, delete it. | [`SDK-API-design.md`](./SDK-API-design.md) | SDK / developer-experience direction for app builders and LLM agents. | | [`json-ad-compact.md`](./json-ad-compact.md) | **In progress:** one compact, class-scoped wire dialect (shortname keys, `@`-structural keys, tag names as values) for all LLM assistant tool I/O; single resolver module; never stored. | | [`llm-wasm-gui-plugins.md`](./llm-wasm-gui-plugins.md) | Proposal for browser-built JS/TS applications with scoped Loro documents, blob checkpoints, and optional future WASM modules. | -| [`personal-information-suite.md`](./personal-information-suite.md) | Exploration: contacts, calendar, and email as first-class Atomic resources, with provider connectors for Google, Microsoft, IMAP/SMTP, CalDAV, and CardDAV. | +| [`contacts.md`](./contacts.md) | **Active:** Address Book + Contact ontology, query-backed UI, VCF import (Google/iCloud/Microsoft export), optional `contactAgent` link. Scopes suite Milestones 1–2 for contacts. | +| [`personal-information-suite.md`](./personal-information-suite.md) | Exploration: contacts, calendar, and email as first-class Atomic resources, with provider connectors for Google, Microsoft, IMAP/SMTP, CalDAV, and CardDAV. Contacts MVP tracked in `contacts.md`. | | [`rust-dependency-upgrade-audit.md`](./rust-dependency-upgrade-audit.md) | Audit notes for the Rust dependency upgrade pass. | | [`cleanup-update-encoding.md`](./cleanup-update-encoding.md) | **Active:** Refactor UPDATE frame encoding/decoding, unify parser, and remove magic numbers. | | [`commit-fanout-drive-isolation.md`](./commit-fanout-drive-isolation.md) | **Active:** Drive-scoped WS commit fan-out — closes the cross-tenant commit leak and the e2e 401-spillover flake; tracks the chatroom guest-drive client-hydration regression and its fix. | diff --git a/planning/contacts.md b/planning/contacts.md new file mode 100644 index 000000000..c4f7c11a1 --- /dev/null +++ b/planning/contacts.md @@ -0,0 +1,264 @@ +# Contacts + +> **Status:** active build plan (2026-08-03). Scopes Milestone 1–2 of +> [`personal-information-suite.md`](./personal-information-suite.md) into a +> shippable Contacts feature: ontology, local Address Book UI over query, +> VCF import (Google / iCloud / Microsoft export), and optional Agent links. +> OAuth connectors and two-way CardDAV sync stay in the suite plan. + +## Goal + +Make people you know first-class Atomic resources so they can be searched, +queried, linked from meetings/chat/docs, and optionally tied to an Atomic +Agent — without pretending a Contact *is* an Agent. + +## Agent vs Contact (critical distinction) + +| | **Agent** | **Contact** | +| --- | --- | --- | +| Class | `https://atomicdata.dev/classes/Agent` | `https://atomicdata.dev/classes/Contact` | +| Identity | `did:ad:agent:{publicKey}` (Ed25519) | Ordinary Drive-scoped resource (`did:ad:…` genesis) | +| Role | Cryptographic actor: signs commits, holds `read`/`write` | Address-book card: name, emails, phones, notes | +| Rights | Subject *of* authorization | Object *under* Drive hierarchy rights | +| Server | Special (invite, auth, publicKey) | Normal resource — no ClassExtender needed for MVP | + +A Contact may link to an Agent via `contactAgent`. That link: + +- does **not** grant the Agent rights on the Contact or Drive; +- does **not** make the Contact sign anything; +- *does* let UIs show “this person is also `@alice`”, resolve attendees, and + reverse-query “which Contact is this Agent?”. + +Many Contacts will never have an Agent (vendors, relatives, paper contacts). +Many Agents will never appear as Contacts (bots, service accounts). + +## How Contacts interact with atomic-server + +Contacts are ordinary Loro-backed resources: + +1. Client `resource.set(…)` → Loro `properties` (+ datatype tags) → `save()` + → signed commit with `loroUpdate` → `POST /commit`. +2. Server `apply_changes()` imports the update, materializes PropVals, indexes + for search (tantivy) and collection queries. +3. Subscribers get `COMMIT` over WebSocket; peers sync via Iroh like any other + Drive child. +4. Authorization is hierarchical: rights on the Address Book / Drive cover + Contacts. The optional Agent link is just an `atomicUrl` property. + +No server plugin, no special commit path, no bootstrap Agent registration. +Server already understands `isA`, Collection filters, and full-text search — +the feature rides those. + +## Ontology + +Bootstrap vocabulary in `lib/defaults/contacts.json`, imported from +`populate_default_store()` (same pattern as `meeting.json`). + +### Classes + +#### `AddressBook` — `https://atomicdata.dev/classes/AddressBook` + +Container for Contacts. Custom page lists children via `useCollection`. + +- **requires:** `name` +- **recommends:** `description` + +Contacts are **not** stored in a property array. They are child resources +(`parent` = Address Book subject), listed with the built-in query stack. + +#### `Contact` — `https://atomicdata.dev/classes/Contact` + +One address-book entry. + +- **requires:** `name` (vCard `FN` / display name) +- **recommends:** properties below + +### Properties + +| Property | URL suffix | Datatype | Notes | +| --- | --- | --- | --- | +| `givenName` | `properties/givenName` | string | vCard `N` given | +| `familyName` | `properties/familyName` | string | vCard `N` family | +| `organization` | `properties/organization` | string | vCard `ORG` | +| `jobTitle` | `properties/jobTitle` | string | vCard `TITLE` | +| `email` | `properties/email` | string | **Primary** email — indexed, filterable | +| `telephone` | `properties/telephone` | string | **Primary** phone — indexed, filterable | +| `emails` | `properties/emails` | json | `[{ "value": "…", "type"?: "work"\|"home"\|… }]` | +| `telephones` | `properties/telephones` | json | same shape | +| `addresses` | `properties/addresses` | json | `[{ "street"?, "locality"?, "region"?, "postalCode"?, "country"?, "type"? }]` | +| `website` | `properties/website` | uri | vCard `URL` | +| `contactAgent` | `properties/contactAgent` | atomicUrl → Agent | Optional linked Agent | +| `vcardUid` | `properties/vcardUid` | string | vCard `UID` for import dedup | +| `contactsFolder` | `properties/contactsFolder` | atomicUrl → Folder | Drive pointer (standard location) | + +Reuse existing props where they already fit: `name`, `description` (notes / +vCard `NOTE` as markdown via `description`), `image` (avatar File). + +Primary `email` / `telephone` are denormalized from the JSON arrays so +Collection filters and search stay simple. Import fills both; the Contact page +keeps them in sync when the user edits the primary. + +### Ontology resource + +Group under `https://atomicdata.dev/ontology/contacts` with `classes` / +`properties` arrays (same pattern as `i18n.json` / `forks.json`). + +### Drive standard location + +`drive.contactsFolder` → Folder named “Contacts”, get-or-create via +`getOrCreateDriveLocation` (see `standardLocations.ts`). New Address Books +default there; sidebar can hide the folder if it stays empty clutter (same +treatment as Comments / AI Chats — decide at UI time; default: **show** so +Contacts are discoverable). + +## Queries (built-in) + +All listing uses `@tomic/react` `useCollection` / Collection resources — no +ad-hoc child arrays. + +```ts +// Contacts in an Address Book +useCollection({ + property: core.properties.parent, + value: addressBook.subject, + filters: [{ property: core.properties.isA, value: dataBrowser.classes.contact }], + sort_by: core.properties.name, +}); + +// Drive-wide contacts +useCollection({ + property: core.properties.isA, + value: dataBrowser.classes.contact, + sort_by: core.properties.name, +}); + +// Reverse: Contact for an Agent +useCollection({ + property: dataBrowser.properties.contactAgent, + value: agentSubject, +}); +``` + +Users can also create a classic **Collection** resource with +`property=isA`, `value=Contact` for a reusable filtered view — CollectionPage +already supports card/table display and `NewInstanceButton`. + +## UI (reuse first) + +### Address Book page + +`views/Contacts/AddressBookPage.tsx` + +- Shell: `ContainerWide`, `EditableTitle`, `Row` / `Column` +- Toolbar: search input (client filter on loaded page + optional + `useServerSearch` later), **Import vCard**, **New Contact** + (`NewInstanceButton` / `useNewResourceUI`) +- Body: `useCollection` → list rows (name, email, telephone, Agent pill via + `ResourceInline` / Agent styling). Prefer dense list (`TableList` or custom + row using `ResourceGlyph` + `AtomicLink`) over card masonry for contacts. +- Empty state: `SkeletonButton` for New + Import + +### Contact page + +`views/Contacts/ContactPage.tsx` + +- Header: name (`EditableTitle`), optional avatar (`image`) +- Identity block: given / family / org / job title +- Reachability: primary email & phone (editable), expand JSON multi-values +- **Linked Agent:** `ResourceSelector` with `isA={core.classes.agent}` + (automatic from `classtype` via `InputSwitcher` / `InputResource`) +- Notes: markdown `description` +- Footer actions: open Agent (if set), delete + +Register in `ResourcePage.selectComponent`, optional Card + `iconMap` +(`FaAddressBook` / `FaUser`). + +### Create flows + +| Action | Behavior | +| --- | --- | +| New → Address Book | Instant create under `contactsFolder` (or current parent), navigate to page | +| New → Contact | Create under parent if parent is AddressBook; else under default Address Book in `contactsFolder` | +| Import vCard | Dialog: drop/select `.vcf` / `.vcard`, preview count + sample names, commit as Contact children | + +Wire: `BaseButtons`, `registerBasicInstanceHandler` / `registerNewResourceDialog`, +`CustomForms/index.ts`. + +### Components to reuse (do not rebuild) + +Dialog / `useDialog`, `Button`, `Field`, `InputStyled`, `ResourceSelector`, +`EditableTitle`, `ContainerWide` / `ContainerNarrow`, `Row` / `Column`, +`NewInstanceButton`, `SkeletonButton`, `FileDropzone` / react-dropzone, +`ResourceInline`, `AtomicLink`, `LoaderInline`, `ConfirmationDialog`. + +## VCF import + +Google Contacts, Apple iCloud, and Microsoft Outlook/People all export +**vCard** (`.vcf`, typically 3.0; sometimes 4.0). No OAuth in this milestone — +user exports from the provider, imports the file. + +### Pipeline (Level 0 importer, per `importers.md`) + +```text +file pick → parse VCF (first-party) → map to Contact props → preview → commit +``` + +- Parser: small first-party module in `browser/data-browser/src/views/Contacts/vcf.ts` + (unfold lines, split `BEGIN:VCARD`…`END:VCARD`, handle `N`, `FN`, `EMAIL`, + `TEL`, `ORG`, `TITLE`, `URL`, `NOTE`, `UID`, `ADR`, `PHOTO` as URL only). + Avoid a heavy dependency unless 3.0/4.0 edge cases force it. +- Dedup: if `vcardUid` matches an existing child Contact, update that resource + instead of creating a duplicate (LWW on props). +- PHOTO binary (`ENCODING=b`) is skipped in MVP; URL photos may set `imageUrl` + if we keep that property, else ignore. +- Batch create with sequential `store.newResource` + `save()`; show progress + toast. For very large books (>500), chunk — still client-side. + +### Tests + +Unit tests on the parser with fixtures that look like Google / Apple / +Outlook exports (minimal representative cards, not full dumps). + +## TypeScript / Rust wiring checklist + +- [ ] `lib/defaults/contacts.json` + import in `populate.rs` +- [ ] URL constants in `lib/src/urls.rs` (optional if only used via JSON) +- [ ] Hand-update `browser/lib/src/ontologies/dataBrowser.ts` (classes, + properties, prop type maps) — CLI regen later if desired +- [ ] `getOrCreateContactsFolder` in `standardLocations.ts` +- [ ] Basic instance handlers + BaseButtons +- [ ] `AddressBookPage` + `ContactPage` + ResourcePage / Card / iconMap +- [ ] VCF parser + import dialog + tests +- [ ] Docs blurb optional; update `planning/README.md` + suite plan pointer +- [ ] `pnpm typecheck` in data-browser; focused unit tests + +## Out of scope (stay in personal-information-suite) + +- Google People / Microsoft Graph / CardDAV OAuth connectors +- Two-way sync, ETag / sync tokens +- Person / ContactProfile / ExternalIdentity split (Contact is enough for now; + suite can introduce Person later and migrate) +- Duplicate-merge UI, organization graph +- Email/calendar deep linking beyond storing the Agent relation +- Server-side ClassExtender behavior + +## Acceptance + +- [ ] Fresh server bootstrap includes Contact + AddressBook vocabulary +- [ ] User can create an Address Book and Contacts from New Resource +- [ ] Address Book page lists Contacts via `useCollection` (query), searchable +- [ ] Contact page edits fields and links an Agent through ResourceSelector +- [ ] Importing a `.vcf` creates Contacts with name/email/phone/uid populated +- [ ] Re-import with same UID updates rather than duplicating +- [ ] Contacts sync/search like any other resource (no special server path) +- [ ] UI reuses Dialog, ResourceSelector, EditableTitle, layout primitives + +## Relationship to other plans + +- Supersedes suite Milestone 1–2 for the Contacts slice only; + [`personal-information-suite.md`](./personal-information-suite.md) remains + the umbrella for calendar/mail/connectors. +- Import architecture aligns with Level 0 / first-party parse in + [`importers.md`](./importers.md). +- Follows Meeting’s packaging pattern (`meeting.json` + dedicated page + + drive folder pointer). diff --git a/planning/personal-information-suite.md b/planning/personal-information-suite.md index dea61a8e4..dad76c83d 100644 --- a/planning/personal-information-suite.md +++ b/planning/personal-information-suite.md @@ -2,9 +2,9 @@ ## Status -Exploration. This plan sketches what it would take to make Atomic a credible -home for contacts, calendars, and email while preserving Atomic's local-first -resource model. +Exploration (suite). Contacts MVP is tracked separately in +[`contacts.md`](./contacts.md) (ontology, Address Book UI, VCF import, Agent +link). This document remains the umbrella for calendar, mail, and connectors. ## Goal From de894bc29e9070054e51b8c1f7f8d4d7cacf019a Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 13:17:04 +0000 Subject: [PATCH 2/3] fix: polish Contacts UI edge cases and mark plan checklist done Guard Address Book rows against unknownSubject, fix dropzone hover background, and update planning/contacts.md acceptance status. Co-authored-by: Joep Meindertsma --- .../src/views/Contacts/AddressBookPage.tsx | 7 +++- .../src/views/Contacts/ImportVCardDialog.tsx | 2 +- .../data-browser/src/views/Contacts/vcf.ts | 2 +- planning/contacts.md | 38 ++++++++++--------- 4 files changed, 29 insertions(+), 20 deletions(-) diff --git a/browser/data-browser/src/views/Contacts/AddressBookPage.tsx b/browser/data-browser/src/views/Contacts/AddressBookPage.tsx index 26944a388..631106544 100644 --- a/browser/data-browser/src/views/Contacts/AddressBookPage.tsx +++ b/browser/data-browser/src/views/Contacts/AddressBookPage.tsx @@ -3,6 +3,7 @@ import { styled } from 'styled-components'; import { core, dataBrowser, + unknownSubject, useCollection, useMemberFromCollection, useString, @@ -150,7 +151,11 @@ function FilteredContactRow({ dataBrowser.properties.organization, ); - if (resource.loading || !resource.subject) { + if ( + resource.loading || + !resource.subject || + resource.subject === unknownSubject + ) { return null; } diff --git a/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx b/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx index 40e8fc8b4..676459a88 100644 --- a/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx +++ b/browser/data-browser/src/views/Contacts/ImportVCardDialog.tsx @@ -187,7 +187,7 @@ const DropZone = styled.div<{ $active: boolean }>` border: 2px dashed ${p => (p.$active ? p.theme.colors.main : p.theme.colors.bg2)}; border-radius: ${p => p.theme.radius}; - background: ${p => (p.$active ? p.theme.colors.mainLight : p.theme.colors.bg)}; + background: ${p => (p.$active ? p.theme.colors.bg1 : p.theme.colors.bg)}; color: ${p => p.theme.colors.textLight}; cursor: pointer; text-align: center; diff --git a/browser/data-browser/src/views/Contacts/vcf.ts b/browser/data-browser/src/views/Contacts/vcf.ts index f03b4bdd5..ed7049ef5 100644 --- a/browser/data-browser/src/views/Contacts/vcf.ts +++ b/browser/data-browser/src/views/Contacts/vcf.ts @@ -113,7 +113,7 @@ function decodeQuotedPrintable(value: string): string { } function firstType(params: Record): string | undefined { - const raw = params.TYPE ?? params.TYPE; + const raw = params.TYPE; if (!raw) { return undefined; diff --git a/planning/contacts.md b/planning/contacts.md index c4f7c11a1..aaca58e3d 100644 --- a/planning/contacts.md +++ b/planning/contacts.md @@ -221,16 +221,18 @@ Outlook exports (minimal representative cards, not full dumps). ## TypeScript / Rust wiring checklist -- [ ] `lib/defaults/contacts.json` + import in `populate.rs` -- [ ] URL constants in `lib/src/urls.rs` (optional if only used via JSON) -- [ ] Hand-update `browser/lib/src/ontologies/dataBrowser.ts` (classes, +- [x] `lib/defaults/contacts.json` + import in `populate.rs` +- [x] URL constants in `lib/src/urls.rs` (optional if only used via JSON) +- [x] Hand-update `browser/lib/src/ontologies/dataBrowser.ts` (classes, properties, prop type maps) — CLI regen later if desired -- [ ] `getOrCreateContactsFolder` in `standardLocations.ts` -- [ ] Basic instance handlers + BaseButtons -- [ ] `AddressBookPage` + `ContactPage` + ResourcePage / Card / iconMap -- [ ] VCF parser + import dialog + tests -- [ ] Docs blurb optional; update `planning/README.md` + suite plan pointer -- [ ] `pnpm typecheck` in data-browser; focused unit tests +- [x] `getOrCreateContactsFolder` in `standardLocations.ts` +- [x] Basic instance handlers + BaseButtons +- [x] `AddressBookPage` + `ContactPage` + ResourcePage / Card / iconMap +- [x] VCF parser + import dialog + tests +- [x] Docs blurb optional; update `planning/README.md` + suite plan pointer +- [x] Focused VCF unit tests (5 passing). Full data-browser typecheck still + fails on pre-existing `pairing.test.ts` `@types/node` issue; Contacts + files have no type errors. ## Out of scope (stay in personal-information-suite) @@ -244,14 +246,16 @@ Outlook exports (minimal representative cards, not full dumps). ## Acceptance -- [ ] Fresh server bootstrap includes Contact + AddressBook vocabulary -- [ ] User can create an Address Book and Contacts from New Resource -- [ ] Address Book page lists Contacts via `useCollection` (query), searchable -- [ ] Contact page edits fields and links an Agent through ResourceSelector -- [ ] Importing a `.vcf` creates Contacts with name/email/phone/uid populated -- [ ] Re-import with same UID updates rather than duplicating -- [ ] Contacts sync/search like any other resource (no special server path) -- [ ] UI reuses Dialog, ResourceSelector, EditableTitle, layout primitives +- [x] Fresh server bootstrap includes Contact + AddressBook vocabulary + (`contacts.json` wired into `populate_default_store`) +- [x] User can create an Address Book and Contacts from New Resource +- [x] Address Book page lists Contacts via `useCollection` (query), searchable +- [x] Contact page edits fields and links an Agent through ResourceSelector + (`classtype=Agent` on `contactAgent` + Edit form / AllProps) +- [x] Importing a `.vcf` creates Contacts with name/email/phone/uid populated +- [x] Re-import with same UID updates rather than duplicating +- [x] Contacts sync/search like any other resource (no special server path) +- [x] UI reuses Dialog, ResourceSelector, EditableTitle, layout primitives ## Relationship to other plans From f3acf6a8ddf73d39c8d8e47c567100c70f804b0f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Mon, 3 Aug 2026 13:25:44 +0000 Subject: [PATCH 3/3] fix(contacts): hide JSON multi-value props from Contact page AllProps Primary email/phone/org already show in the reachability header; the full emails/telephones/addresses JSON arrays are still editable via Edit. Co-authored-by: Joep Meindertsma --- browser/data-browser/src/locales/de.po | 103 ++++++++++++++++++ browser/data-browser/src/locales/en.po | 103 ++++++++++++++++++ browser/data-browser/src/locales/es.po | 103 ++++++++++++++++++ browser/data-browser/src/locales/fr.po | 103 ++++++++++++++++++ .../src/views/Contacts/ContactPage.tsx | 3 + 5 files changed, 415 insertions(+) diff --git a/browser/data-browser/src/locales/de.po b/browser/data-browser/src/locales/de.po index 666f99b36..1c22b5ae5 100644 --- a/browser/data-browser/src/locales/de.po +++ b/browser/data-browser/src/locales/de.po @@ -144,6 +144,7 @@ msgstr "" #: src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx #: src/components/forms/ValueForm/ValueFormEdit.tsx #: src/routes/History/HistoryMobileView.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/OntologyPage/NewClassButton.tsx #: src/views/OntologyPage/NewPropertyButton.tsx #: src/views/Plugin/AssignRights.tsx @@ -541,6 +542,7 @@ msgstr "" #: src/chunks/TablePage/RowActionHeading.tsx #: src/chunks/TablePage/TableHeadingMenu.tsx #: src/routes/EditRoute.tsx +#: src/views/Contacts/ContactPage.tsx #: src/views/OntologyPage/OntologyPage.tsx #: src/views/ResourcePageDefault.tsx msgid "Edit" @@ -1240,6 +1242,7 @@ msgid "Importing..." msgstr "" #: src/actions/resourceActions.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/ImporterPage.tsx msgid "Import" msgstr "" @@ -5526,6 +5529,7 @@ msgstr "" #: src/helpers/knownPeers.test.ts #: src/helpers/knownPeers.test.ts +#: src/views/Contacts/ContactPage.tsx msgid "Phone" msgstr "" @@ -6595,3 +6599,102 @@ msgstr "" #: src/chunks/TablePage/tableTemplates.test.ts msgid "{0} presets \"{1}\", which is not a column" msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> Import vCard" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> New Contact" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Loading contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "No contacts yet. Create one or import a `.vcf` export from Google, iCloud, or Microsoft." +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Email" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Website" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Atomic Agent" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "No contact details yet — use Edit to add them." +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/AddressBookCard.tsx +msgid "Address Book" +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/ContactCard.tsx +msgid "Contact" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "No contacts found in this file." +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Failed to read file" +msgstr "" + +#. 0: result.created; 1: result.created === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Imported {0} contact{1}" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import failed" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import vCard" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop vCard here…" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop a .vcf file, or click to browse" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Export contacts from Google Contacts, iCloud, or Microsoft as a `.vcf` file, then drop it here." +msgstr "" + +#. 0: cards.length - 8 +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "…and {0} more" +msgstr "" + +#. 0: cards.length; 1: cards.length === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "{0} contact{1} ready" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Importing…" +msgstr "" diff --git a/browser/data-browser/src/locales/en.po b/browser/data-browser/src/locales/en.po index 340acedd4..ccf8f81f5 100644 --- a/browser/data-browser/src/locales/en.po +++ b/browser/data-browser/src/locales/en.po @@ -144,6 +144,7 @@ msgstr "<0/> Create New Agent" #: src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx #: src/components/forms/ValueForm/ValueFormEdit.tsx #: src/routes/History/HistoryMobileView.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/OntologyPage/NewClassButton.tsx #: src/views/OntologyPage/NewPropertyButton.tsx #: src/views/Plugin/AssignRights.tsx @@ -541,6 +542,7 @@ msgstr "Optional HTTP headers to send when connecting to this MCP server" #: src/chunks/TablePage/RowActionHeading.tsx #: src/chunks/TablePage/TableHeadingMenu.tsx #: src/routes/EditRoute.tsx +#: src/views/Contacts/ContactPage.tsx #: src/views/OntologyPage/OntologyPage.tsx #: src/views/ResourcePageDefault.tsx msgid "Edit" @@ -1240,6 +1242,7 @@ msgid "Importing..." msgstr "Importing..." #: src/actions/resourceActions.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/ImporterPage.tsx msgid "Import" msgstr "Import" @@ -5560,6 +5563,7 @@ msgstr "Tablet" #: src/helpers/knownPeers.test.ts #: src/helpers/knownPeers.test.ts +#: src/views/Contacts/ContactPage.tsx msgid "Phone" msgstr "Phone" @@ -6629,3 +6633,102 @@ msgstr "{0} types into \"{1}\", which is not a column" #: src/chunks/TablePage/tableTemplates.test.ts msgid "{0} presets \"{1}\", which is not a column" msgstr "{0} presets \"{1}\", which is not a column" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> Import vCard" +msgstr "<0/> Import vCard" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> New Contact" +msgstr "<0/> New Contact" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts…" +msgstr "Filter contacts…" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts" +msgstr "Filter contacts" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Loading contacts…" +msgstr "Loading contacts…" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "No contacts yet. Create one or import a `.vcf` export from Google, iCloud, or Microsoft." +msgstr "No contacts yet. Create one or import a `.vcf` export from Google, iCloud, or Microsoft." + +#: src/views/Contacts/ContactPage.tsx +msgid "Email" +msgstr "Email" + +#: src/views/Contacts/ContactPage.tsx +msgid "Website" +msgstr "Website" + +#: src/views/Contacts/ContactPage.tsx +msgid "Atomic Agent" +msgstr "Atomic Agent" + +#: src/views/Contacts/ContactPage.tsx +msgid "No contact details yet — use Edit to add them." +msgstr "No contact details yet — use Edit to add them." + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/AddressBookCard.tsx +msgid "Address Book" +msgstr "Address Book" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/ContactCard.tsx +msgid "Contact" +msgstr "Contact" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "No contacts found in this file." +msgstr "No contacts found in this file." + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Failed to read file" +msgstr "Failed to read file" + +#. 0: result.created; 1: result.created === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Imported {0} contact{1}" +msgstr "Imported {0} contact{1}" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import failed" +msgstr "Import failed" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import vCard" +msgstr "Import vCard" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop vCard here…" +msgstr "Drop vCard here…" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop a .vcf file, or click to browse" +msgstr "Drop a .vcf file, or click to browse" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Export contacts from Google Contacts, iCloud, or Microsoft as a `.vcf` file, then drop it here." +msgstr "Export contacts from Google Contacts, iCloud, or Microsoft as a `.vcf` file, then drop it here." + +#. 0: cards.length - 8 +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "…and {0} more" +msgstr "…and {0} more" + +#. 0: cards.length; 1: cards.length === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "{0} contact{1} ready" +msgstr "{0} contact{1} ready" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Importing…" +msgstr "Importing…" diff --git a/browser/data-browser/src/locales/es.po b/browser/data-browser/src/locales/es.po index e74d37e8d..3b350f870 100644 --- a/browser/data-browser/src/locales/es.po +++ b/browser/data-browser/src/locales/es.po @@ -144,6 +144,7 @@ msgstr "<0/> Crear Nuevo Agente" #: src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx #: src/components/forms/ValueForm/ValueFormEdit.tsx #: src/routes/History/HistoryMobileView.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/OntologyPage/NewClassButton.tsx #: src/views/OntologyPage/NewPropertyButton.tsx #: src/views/Plugin/AssignRights.tsx @@ -541,6 +542,7 @@ msgstr "Encabezados HTTP opcionales para enviar al conectarse a este servidor MC #: src/chunks/TablePage/RowActionHeading.tsx #: src/chunks/TablePage/TableHeadingMenu.tsx #: src/routes/EditRoute.tsx +#: src/views/Contacts/ContactPage.tsx #: src/views/OntologyPage/OntologyPage.tsx #: src/views/ResourcePageDefault.tsx msgid "Edit" @@ -1240,6 +1242,7 @@ msgid "Importing..." msgstr "Importando..." #: src/actions/resourceActions.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/ImporterPage.tsx msgid "Import" msgstr "Importar" @@ -5526,6 +5529,7 @@ msgstr "" #: src/helpers/knownPeers.test.ts #: src/helpers/knownPeers.test.ts +#: src/views/Contacts/ContactPage.tsx msgid "Phone" msgstr "" @@ -6595,3 +6599,102 @@ msgstr "" #: src/chunks/TablePage/tableTemplates.test.ts msgid "{0} presets \"{1}\", which is not a column" msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> Import vCard" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> New Contact" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Loading contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "No contacts yet. Create one or import a `.vcf` export from Google, iCloud, or Microsoft." +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Email" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Website" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Atomic Agent" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "No contact details yet — use Edit to add them." +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/AddressBookCard.tsx +msgid "Address Book" +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/ContactCard.tsx +msgid "Contact" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "No contacts found in this file." +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Failed to read file" +msgstr "" + +#. 0: result.created; 1: result.created === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Imported {0} contact{1}" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import failed" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import vCard" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop vCard here…" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop a .vcf file, or click to browse" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Export contacts from Google Contacts, iCloud, or Microsoft as a `.vcf` file, then drop it here." +msgstr "" + +#. 0: cards.length - 8 +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "…and {0} more" +msgstr "" + +#. 0: cards.length; 1: cards.length === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "{0} contact{1} ready" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Importing…" +msgstr "" diff --git a/browser/data-browser/src/locales/fr.po b/browser/data-browser/src/locales/fr.po index 8aa044acc..b3e9a2341 100644 --- a/browser/data-browser/src/locales/fr.po +++ b/browser/data-browser/src/locales/fr.po @@ -144,6 +144,7 @@ msgstr "<0/> Créer un nouvel agent" #: src/components/forms/NewForm/CustomCreateActions/CustomForms/NewOntologyDialog.tsx #: src/components/forms/ValueForm/ValueFormEdit.tsx #: src/routes/History/HistoryMobileView.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/OntologyPage/NewClassButton.tsx #: src/views/OntologyPage/NewPropertyButton.tsx #: src/views/Plugin/AssignRights.tsx @@ -541,6 +542,7 @@ msgstr "En-têtes HTTP optionnels à envoyer lors de la connexion à ce serveur #: src/chunks/TablePage/RowActionHeading.tsx #: src/chunks/TablePage/TableHeadingMenu.tsx #: src/routes/EditRoute.tsx +#: src/views/Contacts/ContactPage.tsx #: src/views/OntologyPage/OntologyPage.tsx #: src/views/ResourcePageDefault.tsx msgid "Edit" @@ -1240,6 +1242,7 @@ msgid "Importing..." msgstr "Importation..." #: src/actions/resourceActions.tsx +#: src/views/Contacts/ImportVCardDialog.tsx #: src/views/ImporterPage.tsx msgid "Import" msgstr "Importer" @@ -5526,6 +5529,7 @@ msgstr "" #: src/helpers/knownPeers.test.ts #: src/helpers/knownPeers.test.ts +#: src/views/Contacts/ContactPage.tsx msgid "Phone" msgstr "" @@ -6595,3 +6599,102 @@ msgstr "" #: src/chunks/TablePage/tableTemplates.test.ts msgid "{0} presets \"{1}\", which is not a column" msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> Import vCard" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +#: src/views/Contacts/AddressBookPage.tsx +msgid "<0/> New Contact" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Filter contacts" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "Loading contacts…" +msgstr "" + +#: src/views/Contacts/AddressBookPage.tsx +msgid "No contacts yet. Create one or import a `.vcf` export from Google, iCloud, or Microsoft." +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Email" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Website" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "Atomic Agent" +msgstr "" + +#: src/views/Contacts/ContactPage.tsx +msgid "No contact details yet — use Edit to add them." +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/AddressBookCard.tsx +msgid "Address Book" +msgstr "" + +#: src/components/forms/NewForm/CustomCreateActions/BasicInstanceHandlers.ts +#: src/views/Contacts/ContactCard.tsx +msgid "Contact" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "No contacts found in this file." +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Failed to read file" +msgstr "" + +#. 0: result.created; 1: result.created === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Imported {0} contact{1}" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import failed" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Import vCard" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop vCard here…" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Drop a .vcf file, or click to browse" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Export contacts from Google Contacts, iCloud, or Microsoft as a `.vcf` file, then drop it here." +msgstr "" + +#. 0: cards.length - 8 +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "…and {0} more" +msgstr "" + +#. 0: cards.length; 1: cards.length === 1 ? '' \\: 's' +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "{0} contact{1} ready" +msgstr "" + +#: src/views/Contacts/ImportVCardDialog.tsx +msgid "Importing…" +msgstr "" diff --git a/browser/data-browser/src/views/Contacts/ContactPage.tsx b/browser/data-browser/src/views/Contacts/ContactPage.tsx index de98908c6..bd2aa7c39 100644 --- a/browser/data-browser/src/views/Contacts/ContactPage.tsx +++ b/browser/data-browser/src/views/Contacts/ContactPage.tsx @@ -30,6 +30,9 @@ const contactHiddenProps = [ dataBrowser.properties.jobTitle, dataBrowser.properties.email, dataBrowser.properties.telephone, + dataBrowser.properties.emails, + dataBrowser.properties.telephones, + dataBrowser.properties.addresses, dataBrowser.properties.contactAgent, dataBrowser.properties.website, dataBrowser.properties.vcardUid,