mirror of
https://github.com/rowboatlabs/rowboat.git
synced 2026-05-16 18:25:17 +02:00
email labels
This commit is contained in:
parent
e8a666499a
commit
5131fb7f9e
24 changed files with 2276 additions and 3197 deletions
|
|
@ -17,6 +17,8 @@ import { init as initCalendarSync } from "@x/core/dist/knowledge/sync_calendar.j
|
|||
import { init as initFirefliesSync } from "@x/core/dist/knowledge/sync_fireflies.js";
|
||||
import { init as initGranolaSync } from "@x/core/dist/knowledge/granola/sync.js";
|
||||
import { init as initGraphBuilder } from "@x/core/dist/knowledge/build_graph.js";
|
||||
import { init as initEmailLabeling } from "@x/core/dist/knowledge/label_emails.js";
|
||||
import { init as initNoteTagging } from "@x/core/dist/knowledge/tag_notes.js";
|
||||
import { init as initAgentRunner } from "@x/core/dist/agent-schedule/runner.js";
|
||||
import { initConfigs } from "@x/core/dist/config/initConfigs.js";
|
||||
import started from "electron-squirrel-startup";
|
||||
|
|
@ -170,6 +172,12 @@ app.whenReady().then(async () => {
|
|||
// start knowledge graph builder
|
||||
initGraphBuilder();
|
||||
|
||||
// start email labeling service
|
||||
initEmailLabeling();
|
||||
|
||||
// start note tagging service
|
||||
initNoteTagging();
|
||||
|
||||
// start background agent runner (scheduled agents)
|
||||
initAgentRunner();
|
||||
|
||||
|
|
|
|||
|
|
@ -46,6 +46,7 @@ import {
|
|||
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
|
||||
import { Toaster } from "@/components/ui/sonner"
|
||||
import { stripKnowledgePrefix, toKnowledgePath, wikiLabel } from '@/lib/wiki-links'
|
||||
import { splitFrontmatter, joinFrontmatter, extractTags } from '@/lib/frontmatter'
|
||||
import { OnboardingModal } from '@/components/onboarding-modal'
|
||||
import { SearchDialog } from '@/components/search-dialog'
|
||||
import { BackgroundTaskDetail } from '@/components/background-task-detail'
|
||||
|
|
@ -509,6 +510,10 @@ function App() {
|
|||
const initialContentRef = useRef<string>('')
|
||||
const renameInProgressRef = useRef(false)
|
||||
|
||||
// Frontmatter state: store raw frontmatter per file path, tags for active file
|
||||
const frontmatterByPathRef = useRef<Map<string, string | null>>(new Map())
|
||||
const [activeFileTags, setActiveFileTags] = useState<string[]>([])
|
||||
|
||||
// Version history state
|
||||
const [versionHistoryPath, setVersionHistoryPath] = useState<string | null>(null)
|
||||
const [viewingHistoricalVersion, setViewingHistoricalVersion] = useState<{
|
||||
|
|
@ -892,12 +897,15 @@ function App() {
|
|||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToReload })
|
||||
if (selectedPathRef.current !== pathToReload) return
|
||||
setFileContent(result.data)
|
||||
setEditorContent(result.data)
|
||||
setEditorCacheForPath(pathToReload, result.data)
|
||||
editorContentRef.current = result.data
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(pathToReload, fm)
|
||||
setEditorContent(body)
|
||||
setEditorCacheForPath(pathToReload, body)
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToReload
|
||||
initialContentByPathRef.current.set(pathToReload, result.data)
|
||||
initialContentRef.current = result.data
|
||||
initialContentByPathRef.current.set(pathToReload, body)
|
||||
initialContentRef.current = body
|
||||
setActiveFileTags(extractTags(fm))
|
||||
}
|
||||
}
|
||||
})
|
||||
|
|
@ -926,6 +934,7 @@ function App() {
|
|||
editorContentRef.current = cachedContent
|
||||
editorPathRef.current = selectedPath
|
||||
initialContentRef.current = initialContentByPathRef.current.get(selectedPath) ?? cachedContent
|
||||
setActiveFileTags(extractTags(frontmatterByPathRef.current.get(selectedPath) ?? null))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
|
@ -940,6 +949,8 @@ function App() {
|
|||
const result = await window.ipc.invoke('workspace:readFile', { path: pathToLoad })
|
||||
if (cancelled || fileLoadRequestIdRef.current !== requestId || selectedPathRef.current !== pathToLoad) return
|
||||
setFileContent(result.data)
|
||||
const { raw: fm, body } = splitFrontmatter(result.data)
|
||||
frontmatterByPathRef.current.set(pathToLoad, fm)
|
||||
const normalizeForCompare = (s: string) => s.split('\n').map(line => line.trimEnd()).join('\n').trim()
|
||||
const isSameEditorFile = editorPathRef.current === pathToLoad
|
||||
const knownBaseline = initialContentByPathRef.current.get(pathToLoad)
|
||||
|
|
@ -949,15 +960,16 @@ function App() {
|
|||
&& normalizeForCompare(editorContentRef.current) !== normalizeForCompare(knownBaseline)
|
||||
const shouldPreserveActiveDraft = isSameEditorFile && hasUnsavedEdits
|
||||
if (!shouldPreserveActiveDraft) {
|
||||
setEditorContent(result.data)
|
||||
setEditorContent(body)
|
||||
if (pathToLoad.endsWith('.md')) {
|
||||
setEditorCacheForPath(pathToLoad, result.data)
|
||||
setEditorCacheForPath(pathToLoad, body)
|
||||
}
|
||||
editorContentRef.current = result.data
|
||||
editorContentRef.current = body
|
||||
editorPathRef.current = pathToLoad
|
||||
initialContentByPathRef.current.set(pathToLoad, result.data)
|
||||
initialContentRef.current = result.data
|
||||
initialContentByPathRef.current.set(pathToLoad, body)
|
||||
initialContentRef.current = body
|
||||
setLastSaved(null)
|
||||
setActiveFileTags(extractTags(fm))
|
||||
} else {
|
||||
// Still update the editor's path so subsequent autosaves write to the correct file.
|
||||
editorPathRef.current = pathToLoad
|
||||
|
|
@ -1006,7 +1018,7 @@ function App() {
|
|||
const wasActiveAtStart = selectedPathRef.current === pathAtStart
|
||||
if (wasActiveAtStart) setIsSaving(true)
|
||||
let pathToSave = pathAtStart
|
||||
let contentToSave = debouncedContent
|
||||
let contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, debouncedContent)
|
||||
let renamedFrom: string | null = null
|
||||
let renamedTo: string | null = null
|
||||
try {
|
||||
|
|
@ -1036,16 +1048,21 @@ function App() {
|
|||
renameInProgressRef.current = true
|
||||
await window.ipc.invoke('workspace:rename', { from: pathAtStart, to: targetPath })
|
||||
pathToSave = targetPath
|
||||
contentToSave = rewriteWikiLinksForRenamedFileInMarkdown(
|
||||
const rewrittenBody = rewriteWikiLinksForRenamedFileInMarkdown(
|
||||
debouncedContent,
|
||||
pathAtStart,
|
||||
targetPath
|
||||
)
|
||||
contentToSave = joinFrontmatter(frontmatterByPathRef.current.get(pathAtStart) ?? null, rewrittenBody)
|
||||
renamedFrom = pathAtStart
|
||||
renamedTo = targetPath
|
||||
editorPathRef.current = targetPath
|
||||
untitledRenameReadyPathsRef.current.delete(pathAtStart)
|
||||
setFileTabs(prev => prev.map(tab => (tab.path === pathAtStart ? { ...tab, path: targetPath } : tab)))
|
||||
// Migrate frontmatter entry
|
||||
const fmEntry = frontmatterByPathRef.current.get(pathAtStart)
|
||||
frontmatterByPathRef.current.delete(pathAtStart)
|
||||
frontmatterByPathRef.current.set(targetPath, fmEntry ?? null)
|
||||
initialContentByPathRef.current.delete(pathAtStart)
|
||||
const cachedContent = editorContentByPathRef.current.get(pathAtStart)
|
||||
if (cachedContent !== undefined) {
|
||||
|
|
@ -1070,8 +1087,9 @@ function App() {
|
|||
})
|
||||
}
|
||||
if (selectedPathRef.current === pathAtStart) {
|
||||
editorContentRef.current = contentToSave
|
||||
setEditorContent(contentToSave)
|
||||
const bodyForEditor = splitFrontmatter(contentToSave).body
|
||||
editorContentRef.current = bodyForEditor
|
||||
setEditorContent(bodyForEditor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1083,7 +1101,8 @@ function App() {
|
|||
opts: { encoding: 'utf8' }
|
||||
})
|
||||
markRecentLocalMarkdownWrite(pathToSave)
|
||||
initialContentByPathRef.current.set(pathToSave, contentToSave)
|
||||
// Store body-only baseline (matches what debouncedContent compares against)
|
||||
initialContentByPathRef.current.set(pathToSave, splitFrontmatter(contentToSave).body)
|
||||
|
||||
// If we renamed the active file, update state/history AFTER the write completes so the editor
|
||||
// doesn't reload stale on-disk content mid-typing (which can drop the latest character).
|
||||
|
|
@ -1104,7 +1123,7 @@ function App() {
|
|||
|
||||
// Only update "current file" UI state if we're still on this file
|
||||
if (selectedPathRef.current === pathAtStart || selectedPathRef.current === pathToSave) {
|
||||
initialContentRef.current = contentToSave
|
||||
initialContentRef.current = splitFrontmatter(contentToSave).body
|
||||
setLastSaved(new Date())
|
||||
}
|
||||
} catch (err) {
|
||||
|
|
@ -2162,6 +2181,7 @@ function App() {
|
|||
removeEditorCacheForPath(closingTab.path)
|
||||
initialContentByPathRef.current.delete(closingTab.path)
|
||||
untitledRenameReadyPathsRef.current.delete(closingTab.path)
|
||||
frontmatterByPathRef.current.delete(closingTab.path)
|
||||
if (editorPathRef.current === closingTab.path) {
|
||||
editorPathRef.current = null
|
||||
}
|
||||
|
|
@ -2768,6 +2788,12 @@ function App() {
|
|||
if (editorPathRef.current === oldPath) {
|
||||
editorPathRef.current = newPath
|
||||
}
|
||||
// Migrate frontmatter entry
|
||||
const fmEntry = frontmatterByPathRef.current.get(oldPath)
|
||||
if (fmEntry !== undefined) {
|
||||
frontmatterByPathRef.current.delete(oldPath)
|
||||
frontmatterByPathRef.current.set(newPath, fmEntry)
|
||||
}
|
||||
const baseline = initialContentByPathRef.current.get(oldPath)
|
||||
if (baseline !== undefined) {
|
||||
initialContentByPathRef.current.delete(oldPath)
|
||||
|
|
@ -2805,6 +2831,7 @@ function App() {
|
|||
removeEditorCacheForPath(path)
|
||||
initialContentByPathRef.current.delete(path)
|
||||
untitledRenameReadyPathsRef.current.delete(path)
|
||||
frontmatterByPathRef.current.delete(path)
|
||||
}
|
||||
// Close any file tab showing the deleted file
|
||||
const tabForFile = fileTabs.find(t => t.path === path)
|
||||
|
|
@ -3391,6 +3418,7 @@ function App() {
|
|||
wikiLinks={wikiLinkConfig}
|
||||
onImageUpload={handleImageUpload}
|
||||
editorSessionKey={editorSessionByTabId[tab.id] ?? 0}
|
||||
tags={isActive ? activeFileTags : undefined}
|
||||
onHistoryHandlersChange={(handlers) => {
|
||||
if (handlers) {
|
||||
fileHistoryHandlersRef.current.set(tab.id, handlers)
|
||||
|
|
|
|||
310
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
310
apps/x/apps/renderer/src/components/bases-view.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
import * as React from 'react'
|
||||
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
|
||||
import { ArrowDown, ArrowUp, X } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { splitFrontmatter, extractTags } from '@/lib/frontmatter'
|
||||
|
||||
interface TreeNode {
|
||||
path: string
|
||||
name: string
|
||||
kind: 'file' | 'dir'
|
||||
children?: TreeNode[]
|
||||
stat?: { size: number; mtimeMs: number }
|
||||
}
|
||||
|
||||
type NoteEntry = {
|
||||
path: string
|
||||
name: string
|
||||
folder: string
|
||||
tags: string[]
|
||||
mtimeMs: number
|
||||
}
|
||||
|
||||
type SortField = 'name' | 'folder' | 'mtimeMs'
|
||||
type SortDir = 'asc' | 'desc'
|
||||
|
||||
type BasesViewProps = {
|
||||
tree: TreeNode[]
|
||||
onSelectNote: (path: string) => void
|
||||
}
|
||||
|
||||
function collectFilePaths(nodes: TreeNode[]): { path: string; name: string; mtimeMs: number }[] {
|
||||
return nodes.flatMap((n) =>
|
||||
n.kind === 'file' && n.name.endsWith('.md')
|
||||
? [{ path: n.path, name: n.name.replace(/\.md$/i, ''), mtimeMs: n.stat?.mtimeMs ?? 0 }]
|
||||
: n.children
|
||||
? collectFilePaths(n.children)
|
||||
: [],
|
||||
)
|
||||
}
|
||||
|
||||
/** Build a stable fingerprint from the tree's file paths + mtimes so we only reload when files actually change. */
|
||||
function treeFingerprint(nodes: TreeNode[]): string {
|
||||
const files = collectFilePaths(nodes)
|
||||
return files.map((f) => `${f.path}:${f.mtimeMs}`).join('\n')
|
||||
}
|
||||
|
||||
function getFolder(path: string): string {
|
||||
const parts = path.split('/')
|
||||
if (parts.length >= 3) return parts[1]
|
||||
return ''
|
||||
}
|
||||
|
||||
function formatDate(ms: number): string {
|
||||
if (!ms) return ''
|
||||
const d = new Date(ms)
|
||||
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
|
||||
}
|
||||
|
||||
export function BasesView({ tree, onSelectNote }: BasesViewProps) {
|
||||
const [notes, setNotes] = useState<NoteEntry[]>([])
|
||||
const [initialLoading, setInitialLoading] = useState(true)
|
||||
const [selectedTags, setSelectedTags] = useState<Set<string>>(new Set())
|
||||
const [sortField, setSortField] = useState<SortField>('mtimeMs')
|
||||
const [sortDir, setSortDir] = useState<SortDir>('desc')
|
||||
const lastFingerprintRef = useRef<string>('')
|
||||
|
||||
// Stable fingerprint — only changes when actual file paths/mtimes differ
|
||||
const fingerprint = useMemo(() => treeFingerprint(tree), [tree])
|
||||
|
||||
// Load notes data when fingerprint changes
|
||||
useEffect(() => {
|
||||
if (fingerprint === lastFingerprintRef.current) return
|
||||
lastFingerprintRef.current = fingerprint
|
||||
|
||||
let cancelled = false
|
||||
const files = collectFilePaths(tree)
|
||||
|
||||
async function loadNotes() {
|
||||
const entries: NoteEntry[] = []
|
||||
|
||||
for (const file of files) {
|
||||
try {
|
||||
const result = await window.ipc.invoke('workspace:readFile', {
|
||||
path: file.path,
|
||||
encoding: 'utf8',
|
||||
})
|
||||
const { raw } = splitFrontmatter(result.data)
|
||||
const tags = extractTags(raw)
|
||||
entries.push({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
folder: getFolder(file.path),
|
||||
tags,
|
||||
mtimeMs: file.mtimeMs,
|
||||
})
|
||||
} catch {
|
||||
entries.push({
|
||||
path: file.path,
|
||||
name: file.name,
|
||||
folder: getFolder(file.path),
|
||||
tags: [],
|
||||
mtimeMs: file.mtimeMs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
setNotes(entries)
|
||||
setInitialLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
loadNotes()
|
||||
return () => { cancelled = true }
|
||||
}, [fingerprint, tree])
|
||||
|
||||
// Collect all unique tags
|
||||
const allTags = useMemo(() => {
|
||||
const tagSet = new Set<string>()
|
||||
for (const note of notes) {
|
||||
for (const tag of note.tags) {
|
||||
tagSet.add(tag)
|
||||
}
|
||||
}
|
||||
return [...tagSet].sort((a, b) => a.localeCompare(b))
|
||||
}, [notes])
|
||||
|
||||
// Filter and sort
|
||||
const filteredNotes = useMemo(() => {
|
||||
let result = notes
|
||||
if (selectedTags.size > 0) {
|
||||
const tagsArray = [...selectedTags]
|
||||
result = result.filter((note) =>
|
||||
tagsArray.every((tag) => note.tags.includes(tag)),
|
||||
)
|
||||
}
|
||||
result = [...result].sort((a, b) => {
|
||||
let cmp = 0
|
||||
if (sortField === 'name') {
|
||||
cmp = a.name.localeCompare(b.name)
|
||||
} else if (sortField === 'folder') {
|
||||
cmp = a.folder.localeCompare(b.folder)
|
||||
} else {
|
||||
cmp = a.mtimeMs - b.mtimeMs
|
||||
}
|
||||
return sortDir === 'asc' ? cmp : -cmp
|
||||
})
|
||||
return result
|
||||
}, [notes, selectedTags, sortField, sortDir])
|
||||
|
||||
const toggleTag = useCallback((tag: string) => {
|
||||
setSelectedTags((prev) => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(tag)) {
|
||||
next.delete(tag)
|
||||
} else {
|
||||
next.add(tag)
|
||||
}
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
const clearFilters = useCallback(() => {
|
||||
setSelectedTags(new Set())
|
||||
}, [])
|
||||
|
||||
const handleSort = useCallback((field: SortField) => {
|
||||
setSortField((prev) => {
|
||||
if (prev === field) {
|
||||
setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))
|
||||
return prev
|
||||
}
|
||||
setSortDir(field === 'mtimeMs' ? 'desc' : 'asc')
|
||||
return field
|
||||
})
|
||||
}, [])
|
||||
|
||||
const SortIcon = ({ field }: { field: SortField }) => {
|
||||
if (sortField !== field) return null
|
||||
return sortDir === 'asc' ? (
|
||||
<ArrowUp className="size-3 inline ml-1" />
|
||||
) : (
|
||||
<ArrowDown className="size-3 inline ml-1" />
|
||||
)
|
||||
}
|
||||
|
||||
if (initialLoading) {
|
||||
return (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<div className="space-y-3 w-full max-w-2xl px-6">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<div key={i} className="h-10 rounded bg-muted/50 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex-1 flex flex-col overflow-hidden">
|
||||
{/* Filter bar */}
|
||||
<div className="shrink-0 border-b border-border px-4 py-2.5">
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
Showing {filteredNotes.length} of {notes.length} notes
|
||||
</span>
|
||||
{selectedTags.size > 0 && (
|
||||
<button
|
||||
onClick={clearFilters}
|
||||
className="text-xs text-muted-foreground hover:text-foreground flex items-center gap-1 shrink-0"
|
||||
>
|
||||
<X className="size-3" />
|
||||
Clear filters
|
||||
</button>
|
||||
)}
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{allTags.map((tag) => (
|
||||
<button
|
||||
key={tag}
|
||||
onClick={() => toggleTag(tag)}
|
||||
className={cn(
|
||||
'inline-flex items-center rounded-full border px-2 py-0.5 text-[11px] font-medium transition-colors',
|
||||
selectedTags.has(tag)
|
||||
? 'bg-primary text-primary-foreground border-transparent'
|
||||
: 'bg-transparent text-muted-foreground border-border hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{tag}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
<div className="flex-1 overflow-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="sticky top-0 bg-background border-b border-border">
|
||||
<tr>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('name')}
|
||||
>
|
||||
Name
|
||||
<SortIcon field="name" />
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('folder')}
|
||||
>
|
||||
Folder
|
||||
<SortIcon field="folder" />
|
||||
</th>
|
||||
<th className="text-left px-4 py-2 font-medium text-muted-foreground select-none">
|
||||
Tags
|
||||
</th>
|
||||
<th
|
||||
className="text-left px-4 py-2 font-medium text-muted-foreground cursor-pointer hover:text-foreground select-none"
|
||||
onClick={() => handleSort('mtimeMs')}
|
||||
>
|
||||
Last Modified
|
||||
<SortIcon field="mtimeMs" />
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredNotes.map((note) => (
|
||||
<tr
|
||||
key={note.path}
|
||||
className="border-b border-border/50 hover:bg-accent/50 cursor-pointer transition-colors"
|
||||
onClick={() => onSelectNote(note.path)}
|
||||
>
|
||||
<td className="px-4 py-2 font-medium">{note.name}</td>
|
||||
<td className="px-4 py-2 text-muted-foreground">{note.folder}</td>
|
||||
<td className="px-4 py-2">
|
||||
<div className="flex items-center gap-1 flex-wrap">
|
||||
{note.tags.map((tag) => (
|
||||
<Badge
|
||||
key={tag}
|
||||
variant="secondary"
|
||||
className="text-[10px] px-1.5 py-0 cursor-pointer hover:bg-primary hover:text-primary-foreground"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
toggleTag(tag)
|
||||
}}
|
||||
>
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-2 text-muted-foreground whitespace-nowrap">
|
||||
{formatDate(note.mtimeMs)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{filteredNotes.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={4} className="px-4 py-8 text-center text-muted-foreground">
|
||||
No notes found
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -176,6 +176,7 @@ function getMarkdownWithBlankLines(editor: Editor): string {
|
|||
return result
|
||||
}
|
||||
import { EditorToolbar } from './editor-toolbar'
|
||||
import { TagPills } from './tag-pills'
|
||||
import { WikiLink } from '@/extensions/wiki-link'
|
||||
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandItem, CommandList } from '@/components/ui/command'
|
||||
|
|
@ -200,6 +201,7 @@ interface MarkdownEditorProps {
|
|||
editorSessionKey?: number
|
||||
onHistoryHandlersChange?: (handlers: { undo: () => boolean; redo: () => boolean } | null) => void
|
||||
editable?: boolean
|
||||
tags?: string[]
|
||||
}
|
||||
|
||||
type WikiLinkMatch = {
|
||||
|
|
@ -288,6 +290,7 @@ export function MarkdownEditor({
|
|||
editorSessionKey = 0,
|
||||
onHistoryHandlersChange,
|
||||
editable = true,
|
||||
tags,
|
||||
}: MarkdownEditorProps) {
|
||||
const isInternalUpdate = useRef(false)
|
||||
const wrapperRef = useRef<HTMLDivElement>(null)
|
||||
|
|
@ -721,6 +724,7 @@ export function MarkdownEditor({
|
|||
onSelectionHighlight={setSelectionHighlight}
|
||||
onImageUpload={handleImageUploadWithPlaceholder}
|
||||
/>
|
||||
{tags && <TagPills tags={tags} />}
|
||||
<div className="editor-content-wrapper" ref={wrapperRef} onScroll={handleScroll}>
|
||||
<EditorContent editor={editor} />
|
||||
{wikiLinks ? (
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { useState, useEffect, useCallback } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Plus, X } from "lucide-react"
|
||||
import { useState, useEffect, useCallback, useMemo } from "react"
|
||||
import { Server, Key, Shield, Palette, Monitor, Sun, Moon, Loader2, CheckCircle2, Tags, ChevronRight, Plus, X } from "lucide-react"
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
|
|
@ -22,7 +22,7 @@ import { cn } from "@/lib/utils"
|
|||
import { useTheme } from "@/contexts/theme-context"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance"
|
||||
type ConfigTab = "models" | "mcp" | "security" | "appearance" | "note-tagging"
|
||||
|
||||
interface TabConfig {
|
||||
id: ConfigTab
|
||||
|
|
@ -60,6 +60,13 @@ const tabs: TabConfig[] = [
|
|||
icon: Palette,
|
||||
description: "Customize the look and feel",
|
||||
},
|
||||
{
|
||||
id: "note-tagging",
|
||||
label: "Note Tagging",
|
||||
icon: Tags,
|
||||
path: "config/tags.json",
|
||||
description: "Configure tags for notes and emails",
|
||||
},
|
||||
]
|
||||
|
||||
interface SettingsDialogProps {
|
||||
|
|
@ -685,6 +692,311 @@ function ModelSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
|||
)
|
||||
}
|
||||
|
||||
// --- Note Tagging Settings ---
|
||||
|
||||
interface TagDef {
|
||||
tag: string
|
||||
type: string
|
||||
applicability: "email" | "notes" | "both"
|
||||
description: string
|
||||
example?: string
|
||||
}
|
||||
|
||||
const TAG_TYPE_ORDER = [
|
||||
"relationship", "relationship-sub", "topic", "email-type",
|
||||
"filter", "action", "status", "source",
|
||||
]
|
||||
|
||||
const TAG_TYPE_LABELS: Record<string, string> = {
|
||||
"relationship": "Relationship",
|
||||
"relationship-sub": "Relationship Sub-Tags",
|
||||
"topic": "Topic",
|
||||
"email-type": "Email Type",
|
||||
"filter": "Filter",
|
||||
"action": "Action",
|
||||
"status": "Status",
|
||||
"source": "Source",
|
||||
}
|
||||
|
||||
const DEFAULT_TAGS: TagDef[] = [
|
||||
{ tag: "investor", type: "relationship", applicability: "both", description: "Investors, VCs, or angels", example: "Following up on our meeting — we'd like to move forward with the Series A term sheet." },
|
||||
{ tag: "customer", type: "relationship", applicability: "both", description: "Paying customers", example: "We're seeing great results with Rowboat. Can we discuss expanding to more teams?" },
|
||||
{ tag: "prospect", type: "relationship", applicability: "both", description: "Potential customers", example: "Thanks for the demo yesterday. We're interested in starting a pilot." },
|
||||
{ tag: "partner", type: "relationship", applicability: "both", description: "Business partners", example: "Let's discuss how we can promote the integration to both our user bases." },
|
||||
{ tag: "vendor", type: "relationship", applicability: "both", description: "Service providers you work with", example: "Here are the updated employment agreements you requested." },
|
||||
{ tag: "product", type: "relationship", applicability: "both", description: "Products or services you use (automated)", example: "Your AWS bill for January 2025 is now available." },
|
||||
{ tag: "candidate", type: "relationship", applicability: "both", description: "Job applicants", example: "Thanks for reaching out. I'd love to learn more about the engineering role." },
|
||||
{ tag: "team", type: "relationship", applicability: "both", description: "Internal team members", example: "Here's the updated roadmap for Q2. Let's discuss in our sync." },
|
||||
{ tag: "advisor", type: "relationship", applicability: "both", description: "Advisors, mentors, or board members", example: "I've reviewed the deck. Here are my thoughts on the GTM strategy." },
|
||||
{ tag: "personal", type: "relationship", applicability: "both", description: "Family or friends", example: "Are you coming to Thanksgiving this year? Let me know your travel dates." },
|
||||
{ tag: "press", type: "relationship", applicability: "both", description: "Journalists or media", example: "I'm writing a piece on AI agents. Would you be available for an interview?" },
|
||||
{ tag: "community", type: "relationship", applicability: "both", description: "Users, peers, or open source contributors", example: "Love what you're building with Rowboat. Here's a bug I found..." },
|
||||
{ tag: "government", type: "relationship", applicability: "both", description: "Government agencies", example: "Your Delaware franchise tax is due by March 1, 2025." },
|
||||
{ tag: "primary", type: "relationship-sub", applicability: "notes", description: "Main contact or decision maker", example: "Sarah Chen — VP Engineering, your main point of contact at Acme." },
|
||||
{ tag: "secondary", type: "relationship-sub", applicability: "notes", description: "Supporting contact, involved but not the lead", example: "David Kim — Engineer CC'd on customer emails." },
|
||||
{ tag: "executive-assistant", type: "relationship-sub", applicability: "notes", description: "EA or admin handling scheduling and logistics", example: "Lisa — Sarah's EA who schedules all her meetings." },
|
||||
{ tag: "cc", type: "relationship-sub", applicability: "notes", description: "Person who's CC'd but not actively engaged", example: "Manager looped in for visibility on deal." },
|
||||
{ tag: "referred-by", type: "relationship-sub", applicability: "notes", description: "Person who made an introduction or referral", example: "David Park — Investor who intro'd you to Sarah." },
|
||||
{ tag: "former", type: "relationship-sub", applicability: "notes", description: "Previously held this relationship, no longer active", example: "John — Former customer who churned last year." },
|
||||
{ tag: "champion", type: "relationship-sub", applicability: "notes", description: "Internal advocate pushing for you", example: "Engineer who loves your product and is selling internally." },
|
||||
{ tag: "blocker", type: "relationship-sub", applicability: "notes", description: "Person opposing or blocking progress", example: "CFO resistant to spending on new tools." },
|
||||
{ tag: "sales", type: "topic", applicability: "both", description: "Sales conversations, deals, and revenue", example: "Here's the pricing proposal we discussed. Let me know if you have questions." },
|
||||
{ tag: "support", type: "topic", applicability: "both", description: "Help requests, issues, and customer support", example: "We're seeing an error when trying to export. Can you help?" },
|
||||
{ tag: "legal", type: "topic", applicability: "both", description: "Contracts, terms, compliance, and legal matters", example: "Legal has reviewed the MSA. Attached are our requested changes." },
|
||||
{ tag: "finance", type: "topic", applicability: "both", description: "Money, invoices, payments, banking, and taxes", example: "Your invoice #1234 for $5,000 is attached. Payment due in 30 days." },
|
||||
{ tag: "hiring", type: "topic", applicability: "both", description: "Recruiting, interviews, and employment", example: "We'd like to move forward with a final round interview. Are you available Thursday?" },
|
||||
{ tag: "fundraising", type: "topic", applicability: "both", description: "Raising money and investor relations", example: "Thanks for sending the deck. We'd like to schedule a partner meeting." },
|
||||
{ tag: "travel", type: "topic", applicability: "both", description: "Flights, hotels, trips, and travel logistics", example: "Your flight to Tokyo on March 15 is confirmed. Confirmation #ABC123." },
|
||||
{ tag: "event", type: "topic", applicability: "both", description: "Conferences, meetups, and gatherings", example: "You're invited to speak at TechCrunch Disrupt. Can you confirm your availability?" },
|
||||
{ tag: "shopping", type: "topic", applicability: "both", description: "Purchases, orders, and returns", example: "Your order #12345 has shipped. Track it here." },
|
||||
{ tag: "health", type: "topic", applicability: "both", description: "Medical, wellness, and health-related matters", example: "Your appointment with Dr. Smith is confirmed for Monday at 2pm." },
|
||||
{ tag: "learning", type: "topic", applicability: "both", description: "Courses, education, and skill-building", example: "Welcome to the Advanced Python course. Here's your access link." },
|
||||
{ tag: "research", type: "topic", applicability: "both", description: "Research requests and information gathering", example: "Here's the market analysis you requested on the AI agent space." },
|
||||
{ tag: "intro", type: "email-type", applicability: "both", description: "Warm introduction from someone you know", example: "I'd like to introduce you to Sarah Chen, VP Engineering at Acme." },
|
||||
{ tag: "followup", type: "email-type", applicability: "both", description: "Following up on a previous conversation", example: "Following up on our call last week. Have you had a chance to review the proposal?" },
|
||||
{ tag: "scheduling", type: "email-type", applicability: "email", description: "Meeting and calendar scheduling", example: "Are you available for a call next Tuesday at 2pm?" },
|
||||
{ tag: "cold-outreach", type: "email-type", applicability: "email", description: "Unsolicited contact from someone you don't know", example: "Hi, I noticed your company is growing fast. I'd love to show you how we can help with..." },
|
||||
{ tag: "newsletter", type: "email-type", applicability: "email", description: "Newsletters, marketing emails, and subscriptions", example: "This week in AI: The latest developments in agent frameworks..." },
|
||||
{ tag: "notification", type: "email-type", applicability: "email", description: "Automated alerts, receipts, and system notifications", example: "Your password was changed successfully. If this wasn't you, contact support." },
|
||||
{ tag: "spam", type: "filter", applicability: "email", description: "Junk and unwanted email", example: "Congratulations! You've won $1,000,000..." },
|
||||
{ tag: "promotion", type: "filter", applicability: "email", description: "Marketing offers and sales pitches", example: "50% off all items this weekend only!" },
|
||||
{ tag: "social", type: "filter", applicability: "email", description: "Social media notifications", example: "John Smith commented on your post." },
|
||||
{ tag: "forums", type: "filter", applicability: "email", description: "Mailing lists and group discussions", example: "Re: [dev-list] Question about API design" },
|
||||
{ tag: "action-required", type: "action", applicability: "both", description: "Needs a response or action from you", example: "Can you send me the pricing by Friday?" },
|
||||
{ tag: "fyi", type: "action", applicability: "email", description: "Informational only, no action needed", example: "Just wanted to let you know the deal closed. Thanks for your help!" },
|
||||
{ tag: "urgent", type: "action", applicability: "both", description: "Time-sensitive, needs immediate attention", example: "We need your signature on the contract by EOD today or we lose the deal." },
|
||||
{ tag: "waiting", type: "action", applicability: "both", description: "Waiting on a response from them" },
|
||||
{ tag: "unread", type: "status", applicability: "email", description: "Not yet processed" },
|
||||
{ tag: "to-reply", type: "status", applicability: "email", description: "Need to respond" },
|
||||
{ tag: "done", type: "status", applicability: "email", description: "Handled, can be archived" },
|
||||
{ tag: "active", type: "status", applicability: "notes", description: "Currently relevant, recent activity" },
|
||||
{ tag: "archived", type: "status", applicability: "notes", description: "No longer active, kept for reference" },
|
||||
{ tag: "stale", type: "status", applicability: "notes", description: "No activity in 60+ days, needs attention or archive" },
|
||||
{ tag: "email", type: "source", applicability: "notes", description: "Created or updated from email" },
|
||||
{ tag: "meeting", type: "source", applicability: "notes", description: "Created or updated from meeting transcript" },
|
||||
{ tag: "browser", type: "source", applicability: "notes", description: "Content captured from web browsing" },
|
||||
{ tag: "web-search", type: "source", applicability: "notes", description: "Information from web search" },
|
||||
{ tag: "manual", type: "source", applicability: "notes", description: "Manually entered by user" },
|
||||
{ tag: "import", type: "source", applicability: "notes", description: "Imported from another system" },
|
||||
]
|
||||
|
||||
function NoteTaggingSettings({ dialogOpen }: { dialogOpen: boolean }) {
|
||||
const [tags, setTags] = useState<TagDef[]>([])
|
||||
const [originalTags, setOriginalTags] = useState<TagDef[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set())
|
||||
|
||||
const hasChanges = JSON.stringify(tags) !== JSON.stringify(originalTags)
|
||||
|
||||
useEffect(() => {
|
||||
if (!dialogOpen) return
|
||||
async function load() {
|
||||
setLoading(true)
|
||||
try {
|
||||
const result = await window.ipc.invoke("workspace:readFile", { path: "config/tags.json" })
|
||||
const parsed = JSON.parse(result.data)
|
||||
setTags(parsed)
|
||||
setOriginalTags(parsed)
|
||||
} catch {
|
||||
setTags([...DEFAULT_TAGS])
|
||||
setOriginalTags([...DEFAULT_TAGS])
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
load()
|
||||
}, [dialogOpen])
|
||||
|
||||
const groups = useMemo(() => {
|
||||
const map = new Map<string, TagDef[]>()
|
||||
for (const tag of tags) {
|
||||
const list = map.get(tag.type) ?? []
|
||||
list.push(tag)
|
||||
map.set(tag.type, list)
|
||||
}
|
||||
return TAG_TYPE_ORDER.map(type => ({
|
||||
type,
|
||||
label: TAG_TYPE_LABELS[type],
|
||||
tags: map.get(type) ?? [],
|
||||
}))
|
||||
}, [tags])
|
||||
|
||||
const getGlobalIndex = useCallback((type: string, localIndex: number) => {
|
||||
let count = 0
|
||||
for (let i = 0; i < tags.length; i++) {
|
||||
if (tags[i].type === type) {
|
||||
if (count === localIndex) return i
|
||||
count++
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}, [tags])
|
||||
|
||||
const updateTag = useCallback((index: number, field: keyof TagDef, value: string) => {
|
||||
setTags(prev => prev.map((t, i) => i === index ? { ...t, [field]: value } : t))
|
||||
}, [])
|
||||
|
||||
const removeTag = useCallback((index: number) => {
|
||||
setTags(prev => prev.filter((_, i) => i !== index))
|
||||
}, [])
|
||||
|
||||
const addTag = useCallback((type: string) => {
|
||||
const newTag: TagDef = { tag: "", type, applicability: "both", description: "" }
|
||||
const lastIndex = tags.reduce((acc, t, i) => t.type === type ? i : acc, -1)
|
||||
if (lastIndex === -1) {
|
||||
setTags(prev => [...prev, newTag])
|
||||
} else {
|
||||
setTags(prev => [...prev.slice(0, lastIndex + 1), newTag, ...prev.slice(lastIndex + 1)])
|
||||
}
|
||||
}, [tags])
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaving(true)
|
||||
try {
|
||||
await window.ipc.invoke("workspace:writeFile", {
|
||||
path: "config/tags.json",
|
||||
data: JSON.stringify(tags, null, 2),
|
||||
})
|
||||
setOriginalTags([...tags])
|
||||
toast.success("Tag configuration saved")
|
||||
} catch {
|
||||
toast.error("Failed to save tag configuration")
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}, [tags])
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
if (!confirm("Reset all tags to defaults? This will discard your changes.")) return
|
||||
setTags([...DEFAULT_TAGS])
|
||||
}, [])
|
||||
|
||||
const toggleGroup = useCallback((type: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev)
|
||||
if (next.has(type)) next.delete(type)
|
||||
else next.add(type)
|
||||
return next
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-muted-foreground text-sm">
|
||||
<Loader2 className="size-4 animate-spin mr-2" />
|
||||
Loading...
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col">
|
||||
<div className="flex-1 overflow-y-auto space-y-4 min-h-0">
|
||||
{groups.map(group => (
|
||||
<div key={group.type}>
|
||||
<div className="flex items-center justify-between mb-1.5">
|
||||
<button
|
||||
onClick={() => toggleGroup(group.type)}
|
||||
className="flex items-center gap-1 text-xs font-medium uppercase tracking-wider text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<ChevronRight className={cn("size-3.5 transition-transform", !collapsedGroups.has(group.type) && "rotate-90")} />
|
||||
{group.label}
|
||||
<span className="text-[10px] ml-0.5">({group.tags.length})</span>
|
||||
</button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => addTag(group.type)}
|
||||
>
|
||||
<Plus className="size-3 mr-1" />
|
||||
Add
|
||||
</Button>
|
||||
</div>
|
||||
{!collapsedGroups.has(group.type) && group.tags.length > 0 && (
|
||||
<div className="border rounded-md overflow-hidden">
|
||||
<div className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 bg-muted/50 px-2 py-1 text-[10px] font-medium text-muted-foreground uppercase tracking-wider">
|
||||
<div>Tag</div>
|
||||
<div>Description</div>
|
||||
<div>Example</div>
|
||||
<div>Applies to</div>
|
||||
<div />
|
||||
</div>
|
||||
{group.tags.map((tag, localIdx) => {
|
||||
const globalIdx = getGlobalIndex(group.type, localIdx)
|
||||
return (
|
||||
<div key={globalIdx} className="grid grid-cols-[100px_1fr_1fr_80px_24px] gap-1 border-t px-2 py-0.5 items-center">
|
||||
<Input
|
||||
value={tag.tag}
|
||||
onChange={e => updateTag(globalIdx, "tag", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="tag-name"
|
||||
title={tag.tag}
|
||||
/>
|
||||
<Input
|
||||
value={tag.description}
|
||||
onChange={e => updateTag(globalIdx, "description", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Description"
|
||||
title={tag.description}
|
||||
/>
|
||||
<Input
|
||||
value={tag.example || ""}
|
||||
onChange={e => updateTag(globalIdx, "example", e.target.value)}
|
||||
className="h-7 text-xs"
|
||||
placeholder="Example"
|
||||
title={tag.example || ""}
|
||||
/>
|
||||
<Select
|
||||
value={tag.applicability}
|
||||
onValueChange={v => updateTag(globalIdx, "applicability", v)}
|
||||
>
|
||||
<SelectTrigger className="h-7 text-xs">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="both">Both</SelectItem>
|
||||
<SelectItem value="email">Email</SelectItem>
|
||||
<SelectItem value="notes">Notes</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<button
|
||||
onClick={() => removeTag(globalIdx)}
|
||||
className="flex items-center justify-center text-muted-foreground hover:text-destructive transition-colors"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{!collapsedGroups.has(group.type) && group.tags.length === 0 && (
|
||||
<div className="text-xs text-muted-foreground italic px-2">No tags in this group</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="pt-3 border-t mt-3 flex items-center justify-between">
|
||||
<div>
|
||||
{hasChanges && (
|
||||
<span className="text-xs text-muted-foreground">Unsaved changes</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handleReset}>
|
||||
Reset to defaults
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleSave} disabled={saving || !hasChanges}>
|
||||
{saving ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// --- Main Settings Dialog ---
|
||||
|
||||
export function SettingsDialog({ children }: SettingsDialogProps) {
|
||||
|
|
@ -708,7 +1020,7 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
}
|
||||
|
||||
const loadConfig = useCallback(async (tab: ConfigTab) => {
|
||||
if (tab === "appearance" || tab === "models") return
|
||||
if (tab === "appearance" || tab === "models" || tab === "note-tagging") return
|
||||
const tabConfig = tabs.find((t) => t.id === tab)!
|
||||
if (!tabConfig.path) return
|
||||
setLoading(true)
|
||||
|
|
@ -814,9 +1126,11 @@ export function SettingsDialog({ children }: SettingsDialogProps) {
|
|||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : "overflow-hidden")}>
|
||||
<div className={cn("flex-1 p-4 min-h-0", activeTab === "models" ? "overflow-y-auto" : activeTab === "note-tagging" ? "overflow-hidden flex flex-col" : "overflow-hidden")}>
|
||||
{activeTab === "models" ? (
|
||||
<ModelSettings dialogOpen={open} />
|
||||
) : activeTab === "note-tagging" ? (
|
||||
<NoteTaggingSettings dialogOpen={open} />
|
||||
) : activeTab === "appearance" ? (
|
||||
<AppearanceSettings />
|
||||
) : loading ? (
|
||||
|
|
|
|||
17
apps/x/apps/renderer/src/components/tag-pills.tsx
Normal file
17
apps/x/apps/renderer/src/components/tag-pills.tsx
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
interface TagPillsProps {
|
||||
tags: string[]
|
||||
}
|
||||
|
||||
export function TagPills({ tags }: TagPillsProps) {
|
||||
if (tags.length === 0) return null
|
||||
|
||||
return (
|
||||
<div className="tag-pills-row">
|
||||
{tags.map((tag, i) => (
|
||||
<span key={`${tag}-${i}`} className="tag-pill">
|
||||
{tag}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
164
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
164
apps/x/apps/renderer/src/lib/frontmatter.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Utilities for splitting, joining, and extracting tags from YAML frontmatter
|
||||
* in knowledge notes and email files.
|
||||
*/
|
||||
|
||||
/** Split content into raw frontmatter block and body text. */
|
||||
export function splitFrontmatter(content: string): { raw: string | null; body: string } {
|
||||
if (!content.startsWith('---')) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
const endIndex = content.indexOf('\n---', 3)
|
||||
if (endIndex === -1) {
|
||||
return { raw: null, body: content }
|
||||
}
|
||||
// raw includes both delimiters and the trailing newline after closing ---
|
||||
const closingEnd = endIndex + 4 // '\n---' is 4 chars
|
||||
const raw = content.slice(0, closingEnd)
|
||||
// body starts after the closing --- and its trailing newline
|
||||
let body = content.slice(closingEnd)
|
||||
if (body.startsWith('\n')) {
|
||||
body = body.slice(1)
|
||||
}
|
||||
return { raw, body }
|
||||
}
|
||||
|
||||
/** Re-prepend raw frontmatter before body when saving. */
|
||||
export function joinFrontmatter(raw: string | null, body: string): string {
|
||||
if (!raw) return body
|
||||
return raw + '\n' + body
|
||||
}
|
||||
|
||||
/** Tag category keys used in the categorized frontmatter format. */
|
||||
const TAG_CATEGORY_KEYS = new Set([
|
||||
'relationship',
|
||||
'relationship_sub',
|
||||
'topic',
|
||||
'email_type',
|
||||
'action',
|
||||
'status',
|
||||
'source',
|
||||
])
|
||||
|
||||
/** Keys that are metadata, not tags — skip when collecting tags. */
|
||||
const METADATA_KEYS = new Set(['processed', 'labeled_at', 'tagged_at'])
|
||||
|
||||
/**
|
||||
* Extract tags from raw frontmatter YAML.
|
||||
*
|
||||
* Handles three formats:
|
||||
* - Legacy flat list: `tags:` followed by ` - value` items
|
||||
* - Categorized format: top-level keys like `relationship: customer` or
|
||||
* `topic:` followed by ` - value` list items
|
||||
* - Email format: `labels:` with nested keys (relationship, topics, type, filter, action)
|
||||
* where values can be single strings or ` - value` arrays
|
||||
*
|
||||
* Skips metadata keys like `processed`, `labeled_at`, `tagged_at`.
|
||||
*/
|
||||
export function extractTags(raw: string | null): string[] {
|
||||
if (!raw) return []
|
||||
|
||||
const lines = raw.split('\n')
|
||||
const tags: string[] = []
|
||||
|
||||
let inTags = false
|
||||
let inLabels = false
|
||||
let inLabelSubKey = false
|
||||
let inCategoryList = false
|
||||
|
||||
for (const line of lines) {
|
||||
// Top-level key detection — resets all nested state
|
||||
if (/^\w/.test(line) || line === '---') {
|
||||
inTags = false
|
||||
inLabels = false
|
||||
inLabelSubKey = false
|
||||
inCategoryList = false
|
||||
}
|
||||
|
||||
// Legacy note format: tags:
|
||||
if (/^tags:\s*$/.test(line)) {
|
||||
inTags = true
|
||||
inLabels = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Email format: labels:
|
||||
if (/^labels:\s*$/.test(line)) {
|
||||
inLabels = true
|
||||
inTags = false
|
||||
inCategoryList = false
|
||||
continue
|
||||
}
|
||||
|
||||
// Categorized format: top-level tag category key
|
||||
const topKeyMatch = line.match(/^(\w+):\s*(.*)$/)
|
||||
if (topKeyMatch) {
|
||||
const key = topKeyMatch[1]
|
||||
const inlineValue = topKeyMatch[2].trim()
|
||||
|
||||
if (TAG_CATEGORY_KEYS.has(key)) {
|
||||
if (inlineValue) {
|
||||
// Single value: `relationship: customer`
|
||||
tags.push(inlineValue)
|
||||
inCategoryList = false
|
||||
} else {
|
||||
// List follows: `topic:\n - sales`
|
||||
inCategoryList = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
// Collect tag items under `tags:`
|
||||
if (inTags) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Collect list items under a category key
|
||||
if (inCategoryList) {
|
||||
const match = line.match(/^\s+-\s+(.+)$/)
|
||||
if (match) {
|
||||
tags.push(match[1].trim())
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Handle labels: nested structure
|
||||
if (inLabels) {
|
||||
// Sub-key like ` relationship:` or ` topics:`
|
||||
const subKeyMatch = line.match(/^\s{2}(\w+):\s*(.*)$/)
|
||||
if (subKeyMatch) {
|
||||
const key = subKeyMatch[1]
|
||||
const inlineValue = subKeyMatch[2].trim()
|
||||
if (METADATA_KEYS.has(key)) {
|
||||
inLabelSubKey = false
|
||||
continue
|
||||
}
|
||||
if (inlineValue) {
|
||||
// Inline value like ` type: person`
|
||||
tags.push(inlineValue)
|
||||
inLabelSubKey = false
|
||||
} else {
|
||||
// Array follows
|
||||
inLabelSubKey = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
// Array item under a sub-key like ` - value`
|
||||
if (inLabelSubKey) {
|
||||
const itemMatch = line.match(/^\s{4}-\s+(.+)$/)
|
||||
if (itemMatch) {
|
||||
tags.push(itemMatch[1].trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tags
|
||||
}
|
||||
|
|
@ -237,6 +237,34 @@
|
|||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Tag pills row shown between toolbar and editor content */
|
||||
.tag-pills-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
padding: 4px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
background-color: var(--background);
|
||||
flex-shrink: 0;
|
||||
max-height: 4.5em;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.tag-pill {
|
||||
font-size: 11px;
|
||||
line-height: 18px;
|
||||
padding: 0 8px;
|
||||
border-radius: 9999px;
|
||||
background-color: color-mix(in srgb, var(--foreground) 8%, transparent);
|
||||
color: var(--foreground);
|
||||
white-space: nowrap;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.dark .tag-pill {
|
||||
background-color: color-mix(in srgb, var(--foreground) 12%, transparent);
|
||||
}
|
||||
|
||||
.editor-toolbar .separator {
|
||||
width: 1px;
|
||||
height: 1.5rem;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue