chore: Various improvements and CI setup
- Add Jenkinsfile for CI/CD pipeline - Fix keyboard shortcut '?' handling for help dialog - Update note form and connections components - Add work mode toggle improvements - Update navigation history and usage tracking - Improve validators - Add session summaries
This commit is contained in:
@@ -98,6 +98,16 @@ function ConnectionGroup({
|
||||
)
|
||||
}
|
||||
|
||||
// Deduplicate notes by id, keeping first occurrence
|
||||
function deduplicateById<T extends { id: string }>(items: T[]): T[] {
|
||||
const seen = new Set<string>()
|
||||
return items.filter(item => {
|
||||
if (seen.has(item.id)) return false
|
||||
seen.add(item.id)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
export function NoteConnections({
|
||||
noteId,
|
||||
backlinks,
|
||||
@@ -123,6 +133,13 @@ export function NoteConnections({
|
||||
const hasAnyConnections =
|
||||
backlinks.length > 0 || outgoingLinks.length > 0 || relatedNotes.length > 0 || coUsedNotes.length > 0
|
||||
|
||||
// Deduplicate all lists to prevent React key warnings
|
||||
const uniqueBacklinks = deduplicateById(backlinks.map((bl) => ({ id: bl.sourceNote.id, title: bl.sourceNote.title, type: bl.sourceNote.type })))
|
||||
const uniqueOutgoing = deduplicateById(outgoingLinks.map((ol) => ({ id: ol.sourceNote.id, title: ol.sourceNote.title, type: ol.sourceNote.type })))
|
||||
const uniqueRelated = deduplicateById(relatedNotes.map((rn) => ({ id: rn.id, title: rn.title, type: rn.type })))
|
||||
const uniqueCoUsed = deduplicateById(coUsedNotes.map((cu) => ({ id: cu.noteId, title: cu.title, type: cu.type })))
|
||||
const uniqueHistory = deduplicateById(navigationHistory.slice(0, 5).map((entry) => ({ id: entry.noteId, title: entry.title, type: entry.type })))
|
||||
|
||||
const toggleCollapsed = (key: string) => {
|
||||
setCollapsed((prev) => ({ ...prev, [key]: !prev[key] }))
|
||||
}
|
||||
@@ -144,11 +161,7 @@ export function NoteConnections({
|
||||
<ConnectionGroup
|
||||
title="Enlaces entrantes"
|
||||
icon={ExternalLink}
|
||||
notes={backlinks.map((bl) => ({
|
||||
id: bl.sourceNote.id,
|
||||
title: bl.sourceNote.title,
|
||||
type: bl.sourceNote.type,
|
||||
}))}
|
||||
notes={uniqueBacklinks}
|
||||
emptyMessage="Ningún otro documento enlaza a esta nota"
|
||||
isCollapsed={collapsed['backlinks']}
|
||||
onToggle={() => toggleCollapsed('backlinks')}
|
||||
@@ -158,11 +171,7 @@ export function NoteConnections({
|
||||
<ConnectionGroup
|
||||
title="Enlaces salientes"
|
||||
icon={ArrowRight}
|
||||
notes={outgoingLinks.map((ol) => ({
|
||||
id: ol.sourceNote.id,
|
||||
title: ol.sourceNote.title,
|
||||
type: ol.sourceNote.type,
|
||||
}))}
|
||||
notes={uniqueOutgoing}
|
||||
emptyMessage="Esta nota no enlaza a ningún otro documento"
|
||||
isCollapsed={collapsed['outgoing']}
|
||||
onToggle={() => toggleCollapsed('outgoing')}
|
||||
@@ -172,11 +181,7 @@ export function NoteConnections({
|
||||
<ConnectionGroup
|
||||
title="Relacionadas"
|
||||
icon={RefreshCw}
|
||||
notes={relatedNotes.map((rn) => ({
|
||||
id: rn.id,
|
||||
title: rn.title,
|
||||
type: rn.type,
|
||||
}))}
|
||||
notes={uniqueRelated}
|
||||
emptyMessage="No hay notas relacionadas"
|
||||
isCollapsed={collapsed['related']}
|
||||
onToggle={() => toggleCollapsed('related')}
|
||||
@@ -186,11 +191,7 @@ export function NoteConnections({
|
||||
<ConnectionGroup
|
||||
title="Co-usadas"
|
||||
icon={Users}
|
||||
notes={coUsedNotes.map((cu) => ({
|
||||
id: cu.noteId,
|
||||
title: cu.title,
|
||||
type: cu.type,
|
||||
}))}
|
||||
notes={uniqueCoUsed}
|
||||
emptyMessage="No hay notas co-usadas"
|
||||
isCollapsed={collapsed['coused']}
|
||||
onToggle={() => toggleCollapsed('coused')}
|
||||
@@ -214,15 +215,11 @@ export function NoteConnections({
|
||||
)}
|
||||
|
||||
{/* Navigation history */}
|
||||
{navigationHistory.length > 0 && (
|
||||
{uniqueHistory.length > 0 && (
|
||||
<ConnectionGroup
|
||||
title="Vista recientemente"
|
||||
icon={Clock}
|
||||
notes={navigationHistory.slice(0, 5).map((entry) => ({
|
||||
id: entry.noteId,
|
||||
title: entry.title,
|
||||
type: entry.type,
|
||||
}))}
|
||||
notes={uniqueHistory}
|
||||
emptyMessage="No hay historial de navegación"
|
||||
isCollapsed={collapsed['history']}
|
||||
onToggle={() => toggleCollapsed('history')}
|
||||
|
||||
@@ -816,7 +816,8 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
e.preventDefault()
|
||||
setIsSubmitting(true)
|
||||
|
||||
const noteData = {
|
||||
// Build payload, explicitly excluding id and any undefined values
|
||||
const noteData: Record<string, unknown> = {
|
||||
title,
|
||||
content,
|
||||
type,
|
||||
@@ -825,6 +826,13 @@ export function NoteForm({ initialData, isEdit = false }: NoteFormProps) {
|
||||
tags,
|
||||
}
|
||||
|
||||
// Remove undefined values before sending
|
||||
Object.keys(noteData).forEach(key => {
|
||||
if (noteData[key] === undefined) {
|
||||
delete noteData[key]
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
const url = isEdit && initialData ? `/api/notes/${initialData.id}` : '/api/notes'
|
||||
const method = isEdit ? 'PUT' : 'POST'
|
||||
|
||||
@@ -9,13 +9,22 @@ export function WorkModeToggle() {
|
||||
|
||||
useEffect(() => {
|
||||
setEnabled(getWorkMode())
|
||||
|
||||
const handlePreferencesChange = () => {
|
||||
// Re-read work mode state when preferences change
|
||||
setEnabled(getWorkMode())
|
||||
}
|
||||
|
||||
window.addEventListener('preferences-updated', handlePreferencesChange)
|
||||
return () => window.removeEventListener('preferences-updated', handlePreferencesChange)
|
||||
}, [])
|
||||
|
||||
const toggle = () => {
|
||||
const newValue = !enabled
|
||||
setEnabled(newValue)
|
||||
setWorkMode(newValue)
|
||||
// Could dispatch custom event for other components to listen
|
||||
// Dispatch event so other components know work mode changed
|
||||
window.dispatchEvent(new CustomEvent('work-mode-changed', { detail: { enabled: newValue } }))
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
@@ -27,7 +27,7 @@ export function useGlobalShortcuts() {
|
||||
}
|
||||
|
||||
// Handle ? for help
|
||||
if (e.key === '?' && !e.shiftKey) {
|
||||
if (e.key === '?' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
setShowHelp(true)
|
||||
return
|
||||
|
||||
@@ -13,7 +13,14 @@ export function getNavigationHistory(): NavigationEntry[] {
|
||||
try {
|
||||
const stored = localStorage.getItem(NAVIGATION_HISTORY_KEY)
|
||||
if (!stored) return []
|
||||
return JSON.parse(stored)
|
||||
const entries: NavigationEntry[] = JSON.parse(stored)
|
||||
// Deduplicate by noteId, keeping the first occurrence (most recent)
|
||||
const seen = new Set<string>()
|
||||
return entries.filter(entry => {
|
||||
if (seen.has(entry.noteId)) return false
|
||||
seen.add(entry.noteId)
|
||||
return true
|
||||
})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
|
||||
@@ -176,12 +176,18 @@ export async function getCoUsedNotes(
|
||||
updatedAt: { gte: since },
|
||||
},
|
||||
orderBy: { weight: 'desc' },
|
||||
take: limit,
|
||||
take: limit * 2, // Fetch more to account for duplicates we'll filter
|
||||
})
|
||||
|
||||
// Deduplicate by relatedNoteId - only keep highest weight per note
|
||||
const seenIds = new Set<string>()
|
||||
const result: { noteId: string; title: string; type: string; weight: number }[] = []
|
||||
|
||||
for (const cu of coUsages) {
|
||||
const relatedNoteId = cu.fromNoteId === noteId ? cu.toNoteId : cu.fromNoteId
|
||||
if (seenIds.has(relatedNoteId)) continue
|
||||
seenIds.add(relatedNoteId)
|
||||
|
||||
const note = await prisma.note.findUnique({
|
||||
where: { id: relatedNoteId },
|
||||
select: { id: true, title: true, type: true },
|
||||
@@ -194,6 +200,7 @@ export async function getCoUsedNotes(
|
||||
weight: cu.weight,
|
||||
})
|
||||
}
|
||||
if (result.length >= limit) break
|
||||
}
|
||||
return result
|
||||
} catch {
|
||||
|
||||
@@ -4,8 +4,9 @@ export const NoteTypeEnum = z.enum(['command', 'snippet', 'decision', 'recipe',
|
||||
|
||||
export const CreationSourceEnum = z.enum(['form', 'quick', 'import'])
|
||||
|
||||
export const noteSchema = z.object({
|
||||
id: z.string().optional(),
|
||||
// Base note schema without transform - for use with partial()
|
||||
const baseNoteSchema = z.object({
|
||||
id: z.string().optional().nullable(),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
content: z.string().min(1, 'Content is required'),
|
||||
type: NoteTypeEnum.default('note'),
|
||||
@@ -15,10 +16,18 @@ export const noteSchema = z.object({
|
||||
creationSource: CreationSourceEnum.default('form'),
|
||||
})
|
||||
|
||||
export const updateNoteSchema = noteSchema.partial().extend({
|
||||
id: z.string(),
|
||||
// Transform to remove id if null/undefined (for creation)
|
||||
export const noteSchema = baseNoteSchema.transform(data => {
|
||||
if (data.id == null) {
|
||||
const { id, ...rest } = data
|
||||
return rest
|
||||
}
|
||||
return data
|
||||
})
|
||||
|
||||
// For update, use partial of base schema with optional id (id comes from URL path, not body)
|
||||
export const updateNoteSchema = baseNoteSchema.partial()
|
||||
|
||||
export const searchSchema = z.object({
|
||||
q: z.string().optional(),
|
||||
type: NoteTypeEnum.optional(),
|
||||
|
||||
Reference in New Issue
Block a user