nodepad-hypotheses / lib /nodepad-format.ts
Leon4gr45's picture
Upload folder using huggingface_hub
bfe95fb verified
Raw History Blame Contribute Delete
6.48 kB
/**
* .nodepad file format β€” full-fidelity project serialisation.
*
* A .nodepad file is a JSON object with a version field so future schema
* changes can be handled gracefully. The file contains the complete project
* state: blocks (with all AI annotations, connections, confidence scores,
* sub-tasks), collapsed IDs, ghost notes history, and enough context for
* the AI enrichment pipeline to continue seamlessly on re-import.
*
* Transient UI fields (isEnriching, isError, statusText) are stripped on
* export and reset to safe defaults on import.
*/
import type { ContentType } from "./content-types"
export const NODEPAD_FILE_VERSION = 1 as const
// ── Serialised types ──────────────────────────────────────────────────────────
export interface NodepadBlock {
id: string
text: string
timestamp: number
contentType: ContentType
category?: string
annotation?: string
confidence?: number | null
sources?: { url: string; title: string; siteName: string }[]
influencedBy?: string[] // stable block IDs (not category strings)
isUnrelated?: boolean
isPinned?: boolean
subTasks?: { id: string; text: string; isDone: boolean; timestamp: number }[]
}
export interface NodepadGhostNote {
id: string
text: string
category: string
isGenerating: boolean
}
export interface NodepadFile {
version: typeof NODEPAD_FILE_VERSION
exportedAt: number
project: {
id: string
name: string
blocks: NodepadBlock[]
collapsedIds: string[]
ghostNotes: NodepadGhostNote[]
lastGhostTexts?: string[]
lastGhostBlockCount?: number
lastGhostTimestamp?: number
}
}
// ── Export ────────────────────────────────────────────────────────────────────
/** Serialise a project to a NodepadFile, stripping transient UI state. */
export function serialiseProject(project: {
id: string
name: string
blocks: any[]
collapsedIds: string[]
ghostNotes?: any[]
lastGhostTexts?: string[]
lastGhostBlockCount?: number
lastGhostTimestamp?: number
}): NodepadFile {
return {
version: NODEPAD_FILE_VERSION,
exportedAt: Date.now(),
project: {
id: project.id,
name: project.name,
collapsedIds: project.collapsedIds ?? [],
ghostNotes: (project.ghostNotes ?? []).map((g: any) => ({
id: g.id,
text: g.text,
category: g.category,
isGenerating: false, // never persist a generating state
})),
lastGhostTexts: project.lastGhostTexts,
lastGhostBlockCount: project.lastGhostBlockCount,
lastGhostTimestamp: project.lastGhostTimestamp,
blocks: project.blocks.map((b: any): NodepadBlock => ({
id: b.id,
text: b.text,
timestamp: b.timestamp,
contentType: b.contentType,
...(b.category !== undefined && { category: b.category }),
...(b.annotation !== undefined && { annotation: b.annotation }),
...(b.confidence !== undefined && { confidence: b.confidence }),
...(b.sources !== undefined && { sources: b.sources }),
...(b.influencedBy !== undefined && { influencedBy: b.influencedBy }),
...(b.isUnrelated !== undefined && { isUnrelated: b.isUnrelated }),
...(b.isPinned && { isPinned: b.isPinned }),
...(b.subTasks?.length && { subTasks: b.subTasks }),
})),
},
}
}
/** Trigger a browser download of a .nodepad file for the given project. */
export function downloadNodepadFile(project: {
id: string; name: string; blocks: any[]; collapsedIds: string[]
ghostNotes?: any[]; lastGhostTexts?: string[]
lastGhostBlockCount?: number; lastGhostTimestamp?: number
}): void {
const data = serialiseProject(project)
const blob = new Blob([JSON.stringify(data, null, 2)], {
type: "application/json;charset=utf-8",
})
const url = URL.createObjectURL(blob)
const a = document.createElement("a")
const slug = project.name
.toLowerCase()
.replace(/\s+/g, "-")
.replace(/[^a-z0-9-]/g, "")
.slice(0, 60)
a.href = url
a.download = `${slug || "project"}.nodepad`
a.click()
URL.revokeObjectURL(url)
}
// ── Import ────────────────────────────────────────────────────────────────────
export class NodepadParseError extends Error {}
/**
* Parse a .nodepad file string and return a project object ready to push
* into the projects list. Always assigns a fresh ID so there are no
* collisions. Name conflicts get a copy suffix: "My Research (2)".
*/
export function parseNodepadFile(
raw: string,
existingNames: string[],
): {
id: string
name: string
blocks: any[]
collapsedIds: string[]
ghostNotes: any[]
lastGhostTexts?: string[]
lastGhostBlockCount?: number
lastGhostTimestamp?: number
} {
let data: any
try {
data = JSON.parse(raw)
} catch {
throw new NodepadParseError("Not a valid .nodepad file β€” JSON parse failed.")
}
if (!data || typeof data !== "object") {
throw new NodepadParseError("Not a valid .nodepad file β€” unexpected root type.")
}
if (!data.project || !Array.isArray(data.project.blocks)) {
throw new NodepadParseError("Not a valid .nodepad file β€” missing project.blocks array.")
}
const src = data.project
// Deduplicate name β€” always "Name (2)", "Name (3)", …
let name = (src.name as string) || "Imported Project"
if (existingNames.includes(name)) {
let n = 2
while (existingNames.includes(`${name} (${n})`)) n++
name = `${name} (${n})`
}
return {
id: Math.random().toString(36).substring(2, 10),
name,
blocks: (src.blocks as any[]).map(b => ({
...b,
isEnriching: false,
isError: false,
statusText: undefined,
})),
collapsedIds: Array.isArray(src.collapsedIds) ? src.collapsedIds : [],
ghostNotes: Array.isArray(src.ghostNotes) ? src.ghostNotes : [],
lastGhostTexts: src.lastGhostTexts,
lastGhostBlockCount: src.lastGhostBlockCount,
lastGhostTimestamp: src.lastGhostTimestamp,
}
}