docs: added comments

This commit is contained in:
darwincereska
2025-12-13 21:06:31 -05:00
parent 7d38aed1e0
commit ec8c649150
5 changed files with 525 additions and 127 deletions

View File

@@ -1,60 +1,106 @@
package org.notevc.commands package org.notevc.commands
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.kargs.*
import org.notevc.core.* import org.notevc.core.*
import org.notevc.utils.FileUtils import org.notevc.utils.FileUtils
import org.notevc.utils.HashUtils import org.notevc.utils.HashUtils
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import java.nio.file.Files import java.nio.file.Files
import java.time.Instant import java.time.Instant
import kotlin.io.path.* import kotlin.io.path.exists
import org.kargs.*
/**
* Command for creating commits of changed files.
*
* The CommitCommand handles two main scenarios:
* 1. Committing a specific file (when --file is specified)
* 2. Committing all changed markdown files in the repository
*
* The commit process involves:
* - Parsing markdown files into blocks
* - Detecting changes by comparing with previous snapshots
* - Storing new snapshots for changed files
* - Creating a commit entry in the timeline
* - Updating repository metadata
*/
class CommitCommand : Subcommand("commit", description = "Create a commit of changed files") { class CommitCommand : Subcommand("commit", description = "Create a commit of changed files") {
/** Optional target file to commit (if not specified, commits all changed files) */
val targetFile by Option(ArgType.readableFile(), longName = "file", shortName = "f", description = "Commit only a specific file") val targetFile by Option(ArgType.readableFile(), longName = "file", shortName = "f", description = "Commit only a specific file")
/** Required commit message describing the changes */
val message by Argument(ArgType.String, name = "message", description = "Message for commit", required = true) val message by Argument(ArgType.String, name = "message", description = "Message for commit", required = true)
/**
* Main execution method for the commit command.
*
* Determines whether to commit a single file or all changed files,
* then delagates to the appropiate method.
*/
override fun execute() { override fun execute() {
val result: Result<String> = runCatching { val result: Result<String> = runCatching {
// Find the repository starting from current directory
val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.") val repo = Repository.find() ?: throw Exception("Not in a notevc repository. Run `notevc init` first.")
if (targetFile != null) { if (targetFile != null) {
// Commit only the specified file
createSingleFileCommit(repo, targetFile.toString(), message!!) createSingleFileCommit(repo, targetFile.toString(), message!!)
} else { } else {
// Commit all changed files
createChangedFilesCommit(repo, message!!) createChangedFilesCommit(repo, message!!)
} }
} }
// Display results with appropiate formatting
result.onSuccess { message -> println(message) } result.onSuccess { message -> println(message) }
result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") } result.onFailure { error -> println("${Colors.error("Error:")} ${error.message}") }
} }
/**
* Creates a commit for a single specified file.
*
* This method:
* 1. Validates the target file exists and is a markdown file
* 2. Parses the file into blocks
* 3. Checks if the file is enabled (not disabled in frontmatter)
* 4. Compares with the latest snapshot to detect changes
* 5. Creates a new snapshot if changes are found
* 6. Updates repository metadata
*
* @param repo The repository to commit to
* @param targetFile Path to the file to commit
* @param message Commit message
* @return Success message with commit details
*/
private fun createSingleFileCommit(repo: Repository, targetFile: String, message: String): String { private fun createSingleFileCommit(repo: Repository, targetFile: String, message: String): String {
// Initialize storage components
val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects")) val objectStore = ObjectStore(repo.path.resolve("${Repository.NOTEVC_DIR}/objects"))
val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks")) val blockStore = BlockStore(objectStore, repo.path.resolve("${Repository.NOTEVC_DIR}/blocks"))
val blockParser = BlockParser() val blockParser = BlockParser()
val timestamp = Instant.now() val timestamp = Instant.now()
// Resolve the target file path // Validate and resolve target file path
val filePath = repo.path.resolve(targetFile) val filePath = repo.path.resolve(targetFile)
if (!filePath.exists()) { if (!filePath.exists()) {
throw Exception("File not found: $targetFile") throw Exception("File not found: $targetFile")
} }
// Only markdown files are supported
if (!targetFile.toString().endsWith(".md")) { if (!targetFile.toString().endsWith(".md")) {
throw Exception("Only markdown files (.md) are supported") throw Exception("Only markdown files (.md) are supported")
} }
// Convert to relative path for storage
val relativePath = repo.path.relativize(filePath).toString() val relativePath = repo.path.relativize(filePath).toString()
val content = Files.readString(filePath) val content = Files.readString(filePath)
val parsedFile = blockParser.parseFile(content, relativePath) val parsedFile = blockParser.parseFile(content, relativePath)
// Check if file is disabled // Respect the enabled flag in front matter
if (parsedFile.frontMatter?.isEnabled == false) { if (parsedFile.frontMatter?.isEnabled == false) {
throw Exception("File $targetFile is disabled (enabled: false in front matter)") throw Exception("File $targetFile is disabled (enabled: false in front matter)")
} }
// Check if file actually changed // Check if the file has actually changed since last commit
val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath) val latestSnapshot = blockStore.getLatestBlockSnapshot(relativePath)
if (latestSnapshot != null) { if (latestSnapshot != null) {
val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp) val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore, timestamp)
@@ -65,13 +111,16 @@ class CommitCommand : Subcommand("commit", description = "Create a commit of cha
} }
} }
// Store blocks for this file // Store the new snapshot
val snapshot = blockStore.storeBlocks(parsedFile, timestamp) val snapshot = blockStore.storeBlocks(parsedFile, timestamp)
// Generate a short commit hash for display
val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8) val commitHash = HashUtils.sha256("$timestamp:$message:$relativePath").take(8)
// Update repository metadata // Update repository metadata with new commit
updateRepositoryHead(repo, commitHash, timestamp, message) updateRepositoryHead(repo, commitHash, timestamp, message)
// Return formatted success message
return buildString { return buildString {
appendLine("${Colors.success("Created commit")} ${Colors.yellow(commitHash)}") appendLine("${Colors.success("Created commit")} ${Colors.yellow(commitHash)}")
appendLine("${Colors.bold("Message:")} $message") appendLine("${Colors.bold("Message:")} $message")

View File

@@ -2,8 +2,27 @@ package org.notevc.core
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
/**
* Parses a markdown file into structured blocks
*
* This class is responsible for breaking down markdown files into manageable blocks
* that can be individually tracked and versioned. Each block represents a section
* of content, typically around headings.
*/
class BlockParser { class BlockParser {
// Parse markdown file into blocks based on headings /**
* Parses a markdown file into structured blocks
*
* The parsing process:
* 1. Extracts YAML frontmatter if present
* 2. Splits content into blocks based on headings (lines starting with #)
* 3. Generates stable IDs for each block for tracking purposes
* 4. Handles content that appears before any heading
*
* @param content The raw markdown content to parse
* @param filePath The path of the file being parsed (used for ID generation)
* @return ParsedFile containing the structured blocks and metadata
*/
fun parseFile(content: String, filePath: String): ParsedFile { fun parseFile(content: String, filePath: String): ParsedFile {
val lines = content.lines() val lines = content.lines()
val blocks = mutableListOf<Block>() val blocks = mutableListOf<Block>()
@@ -13,6 +32,7 @@ class BlockParser {
var currentHeading: String? = null var currentHeading: String? = null
var blockIndex = 0 var blockIndex = 0
// Skip front matter lines if present
val contentLines = if (frontMatter != null) { val contentLines = if (frontMatter != null) {
lines.drop(frontMatter.endLine + 1) lines.drop(frontMatter.endLine + 1)
} else lines } else lines
@@ -20,7 +40,7 @@ class BlockParser {
for (line in contentLines) { for (line in contentLines) {
when { when {
line.startsWith("#") -> { line.startsWith("#") -> {
// Save previous block if exists // Save the previous block before starting a new one
if (currentBlock != null && currentHeading != null) { if (currentBlock != null && currentHeading != null) {
blocks.add(Block( blocks.add(Block(
id = generateBlockId(filePath, currentHeading, blockIndex), id = generateBlockId(filePath, currentHeading, blockIndex),
@@ -31,17 +51,17 @@ class BlockParser {
)) ))
} }
// Start new block // Start a new block with this heading
currentHeading = line currentHeading = line
currentBlock = mutableListOf(line) currentBlock = mutableListOf(line)
} }
else -> { else -> {
// Add to current block or create content-only block // Add content to the current block
if (currentBlock != null) currentBlock.add(line) if (currentBlock != null) currentBlock.add(line)
else { else {
// Content before any heading // Handle content that appears before any heading
currentBlock = mutableListOf() currentBlock = mutableListOf()
currentHeading = "<!-- Content -->" currentHeading = "<!-- Content -->" // Special marker for content-only blocks
currentBlock.add(line) currentBlock.add(line)
} }
} }
@@ -66,13 +86,30 @@ class BlockParser {
) )
} }
// Extract YAML front matter /**
* Extracts YAML frontmatter from the beginning of a markdown file.
*
* Front matter is expected to be in this format:
* ---
* key: value
* array_key:
* - item1
* - item2
* - item3
* ---
*
* @param lines The lines of the file to process
* @return FrontMatter object if found, null otherwise
*/
private fun extractFrontMatter(lines: List<String>): FrontMatter? { private fun extractFrontMatter(lines: List<String>): FrontMatter? {
// Front matter must start with --- on the first line
if (lines.isEmpty() || lines[0] != "---") return null if (lines.isEmpty() || lines[0] != "---") return null
// Find the closing --- delimiter
val endIndex = lines.drop(1).indexOfFirst { it == "---"} val endIndex = lines.drop(1).indexOfFirst { it == "---"}
if (endIndex == -1) return null if (endIndex == -1) return null
// Extract the YAML content between the delimiters
val yamlLines = lines.subList(1, endIndex + 1) val yamlLines = lines.subList(1, endIndex + 1)
val properties = mutableMapOf<String, String>() val properties = mutableMapOf<String, String>()
var currentKey: String? = null var currentKey: String? = null
@@ -86,8 +123,8 @@ class BlockParser {
arrayValues.add(value) arrayValues.add(value)
} }
// Handle key-value pairs // Handle key-value pairs
line.contains(":") -> { ":" in line -> {
// Save previous array if exists // Save any accumulated array values from the previous key
if (currentKey != null && arrayValues.isNotEmpty()) { if (currentKey != null && arrayValues.isNotEmpty()) {
properties[currentKey] = arrayValues.joinToString(", ") properties[currentKey] = arrayValues.joinToString(", ")
arrayValues.clear() arrayValues.clear()
@@ -101,6 +138,7 @@ class BlockParser {
// This might be an array key // This might be an array key
currentKey = key currentKey = key
} else { } else {
// Simple key-value pair
properties[key] = value properties[key] = value
currentKey = null currentKey = null
} }
@@ -115,27 +153,49 @@ class BlockParser {
return FrontMatter( return FrontMatter(
properties = properties, properties = properties,
endLine = endIndex + 1 endLine = endIndex + 1 // Track where front matter ends
) )
} }
// Generate stable block id /**
* Generates a stable, unique identifier for a block.
*
* This ID is based on the file path, heading content, and order within the file.
* This ensures that the same block will always get the same ID, enabling
* proper tracking across versions.
*
* @param filePath The path of the file containing the block
* @param heading The heading text of the block
* @param order The position of the block within the file
* @return A 12-character hash that uniquely identifies this block
*/
private fun generateBlockId(filePath: String, heading: String, order: Int): String { private fun generateBlockId(filePath: String, heading: String, order: Int): String {
// Clean the heading by removing markdown syntax
val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim() val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim()
val baseId = "$filePath:$cleanHeading:$order" val baseId = "$filePath:$cleanHeading:$order"
// Use the first 12 characters of SHA-256 hash for a compact but unique ID
return org.notevc.utils.HashUtils.sha256(baseId).take(12) return org.notevc.utils.HashUtils.sha256(baseId).take(12)
} }
// Reconstruct file from blocks /**
* Reconstructs a complete markdown file from parsed blocks.
*
* This is the inverse operation of parseFile(). It takes structured blocks
* and reassembles them into a valid markdown file, preserving the original
* format including the front matter.
*
* @param parsedFile The parsed file structure to reconstruct
* @return The complete markdown content as a string
*/
fun reconstructFile(parsedFile: ParsedFile): String { fun reconstructFile(parsedFile: ParsedFile): String {
val result = StringBuilder() val result = StringBuilder()
// Add front matter if exists // Reconstruct front matter if it exists
parsedFile.frontMatter?.let { fm -> parsedFile.frontMatter?.let { fm ->
result.appendLine("---") result.appendLine("---")
fm.properties.forEach { (key, value) -> fm.properties.forEach { (key, value) ->
// Handle tags as array // Special handling for tags - convert back to array format
if (key == "tags" && value.contains(",")) { if (key == "tags" && "," in value) {
result.appendLine("$key:") result.appendLine("$key:")
value.split(",").forEach { tag -> value.split(",").forEach { tag ->
result.appendLine(" - ${tag.trim()}") result.appendLine(" - ${tag.trim()}")
@@ -145,10 +205,10 @@ class BlockParser {
} }
} }
result.appendLine("---") result.appendLine("---")
result.appendLine() result.appendLine() // Empty line after the front matter
} }
// Add blocks in order // Reconstruct blocks in their original order
val sortedBlocks = parsedFile.blocks.sortedBy { it.order } val sortedBlocks = parsedFile.blocks.sortedBy { it.order }
sortedBlocks.forEachIndexed { index, block -> sortedBlocks.forEachIndexed { index, block ->
result.append(block.content) result.append(block.content)
@@ -162,35 +222,63 @@ class BlockParser {
} }
} }
/**
* Represents a parsed markdown file with its constuitent blocks and metadata.
*/
@Serializable @Serializable
data class ParsedFile( data class ParsedFile(
val path: String, val path: String, // File path for identification
val frontMatter: FrontMatter?, val frontMatter: FrontMatter?, // YAML front matter if present
val blocks: List<Block> val blocks: List<Block> // The content blocks
) )
/**
* Represents a single content block within a markdown file.
*
* Blocks are the fundamental unit of content tracking in the system.
* Each block corresponds to a section of the document, typically organized around headings.
*/
@Serializable @Serializable
data class Block( data class Block(
val id: String, // Stable block identifier val id: String, // Stable identifier for tracking
val heading: String, // The heading text val heading: String, // The heading text (or special marker)
val content: String, // Full block content including heading val content: String, // Full block content including heading
val type: BlockType, val type: BlockType, // Type classification
val order: Int // Order within file val order: Int // Position within the file
) )
/**
* Represents a YAML front matter found at the beginning of a markdown file.
*
* Front matter contains metadata about the document such as title, tags, and configuration options.
*/
@Serializable @Serializable
data class FrontMatter( data class FrontMatter(
val properties: Map<String, String>, val properties: Map<String, String>, // Key-value pairs from the YAML
val endLine: Int val endLine: Int // Line number where front matter ends
) { ) {
// Default to true if not specified // Convenience properties for common frontmatter fields
/** Whether this file is enabled for processing (defaults to true) */
val isEnabled: Boolean get() = properties["enabled"]?.lowercase() != "false" val isEnabled: Boolean get() = properties["enabled"]?.lowercase() != "false"
/** Whether this file should be processed automatically */
val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true" val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true"
/** The title of the document */
val title: String? get() = properties["title"] val title: String? get() = properties["title"]
/** List of tags associated with the document */
val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList() val tags: List<String> get() = properties["tags"]?.split(",")?.map { it.trim() } ?: emptyList()
} }
/**
* Enumeration of different types of content blocks.
*/
enum class BlockType { enum class BlockType {
HEADING_SECTION, // # Heading with content /** A section with a heading and associated content. */
CONTENT_ONLY // Content without heading HEADING_SECTION,
/** Content that appears without an associated heading */
CONTENT_ONLY
} }

View File

@@ -1,29 +1,53 @@
package org.notevc.core package org.notevc.core
import org.notevc.utils.HashUtils
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path import java.nio.file.Path
import java.time.Instant import java.time.Instant
import kotlin.io.path.* import kotlin.io.path.exists
import kotlin.io.path.isRegularFile
/**
* Manages storage and retrieval of block snapshots over time.
*
* The BlockStore is responsible for:
* - Storing snapshots of file blocks at specific points in time
* - Retrieving historical versions of blocks
* - Comparing blocks between different snapshots to detect changes
* - Organizing snapshots in a time-based directory structure
*
* Storage structure: .notevc/blocks/yyyy/mm/dd/blocks-HH-MM-SS-filename.json
*/
class BlockStore( class BlockStore(
private val objectStore: ObjectStore, private val objectStore: ObjectStore, // For storing actual content
private val blocksDir: Path private val blocksDir: Path // Directory for block snapshots
) { ) {
private val json = Json { prettyPrint = true } private val json = Json { prettyPrint = true }
// Store blocks from a parsed file and return block snapshot /**
* Stores blocks from a parsed file and creates a snapshot
*
* This method:
* 1. Stores the actual content of each block in the object store
* 2. Creates a snapshot containing metadata and content hashes
* 3. Saves the snapshot with a timestamp-based filename
*
* @param parsedFile The parsed file containing blocks to store
* @param timestamp When this snapshot was created
* @return BlockSnapshot representing the stored state
*/
fun storeBlocks(parsedFile: ParsedFile, timestamp: Instant): BlockSnapshot { fun storeBlocks(parsedFile: ParsedFile, timestamp: Instant): BlockSnapshot {
// Convert blocks to states with content hashes
val blockStates = parsedFile.blocks.map { block -> val blockStates = parsedFile.blocks.map { block ->
// Store the actual content and get its hash for deduplication
val contentHash = objectStore.storeContent(block.content) val contentHash = objectStore.storeContent(block.content)
BlockState( BlockState(
id = block.id, id = block.id,
heading = block.heading, heading = block.heading,
contentHash = contentHash, contentHash = contentHash, // Reference to content, not the content itself
type = block.type, type = block.type,
order = block.order order = block.order
) )
@@ -36,28 +60,53 @@ class BlockStore(
frontMatter = parsedFile.frontMatter frontMatter = parsedFile.frontMatter
) )
// Store block snapshot with time-based structure (yyyy/mm/dd) // Store the snapshot in the time-based directory structure
storeBlockSnapshot(snapshot) storeBlockSnapshot(snapshot)
return snapshot return snapshot
} }
// Get blocks for a file at a specific time /**
* Retrieves blocks for a file as they existed at a specific time
*
* @param filePath The path of the file to retrieve
* @param timestamp The point in time to retrieve blocks for
* @return List of blocks if found, null if no snapshot exists before that time
*/
fun getBlocksAtTime(filePath: String, timestamp: Instant): List<Block>? { fun getBlocksAtTime(filePath: String, timestamp: Instant): List<Block>? {
// Retrieve the latest block snapshot before the given timestamp
val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp) val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp)
// Use let to reconstruct blocks only if is not null
return snapshot?.let { reconstructBlocks(it) } return snapshot?.let { reconstructBlocks(it) }
} }
// Get current blocks for a file /**
* Retrieves the current (most recent) blocks for a file.
*
* @param filePath The path of the file to retrieve
* @return List of current blocks if found, null if file has no snapshots
*/
fun getCurrentBlocks(filePath: String): List<Block>? { fun getCurrentBlocks(filePath: String): List<Block>? {
val snapshot = getLatestBlockSnapshot(filePath) val snapshot = getLatestBlockSnapshot(filePath)
return snapshot?.let { reconstructBlocks(it) } return snapshot?.let { reconstructBlocks(it) }
} }
// Compare blocks between two snapshots /**
* Compares two block snapshots and identifies what changed.
*
* This method performs a detailed diff between snapshots, identifying:
* - Added blocks (present in new but not in old)
* - Modified blocks (content hash changed)
* - Deleted blocks (present in old but not new)
*
* @param oldSnapshot The previous snapshot(can be null for initial commit)
* @param newSnapshot The new snapshot to compare against
* @return List of changes between the snapshots
*/
fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot?): List<BlockChange> { fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot?): List<BlockChange> {
val changes = mutableListOf<BlockChange>() val changes = mutableListOf<BlockChange>()
// Create maps for efficient lookup by block ID
val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() val newBlocks = newSnapshot?.blocks?.associateBy { it.id } ?: emptyMap()
@@ -66,6 +115,7 @@ class BlockStore(
val oldBlock = oldBlocks[id] val oldBlock = oldBlocks[id]
when { when {
oldBlock == null -> { oldBlock == null -> {
// Block is new
changes.add(BlockChange( changes.add(BlockChange(
blockId = id, blockId = id,
type = BlockChangeType.ADDED, type = BlockChangeType.ADDED,
@@ -74,6 +124,7 @@ class BlockStore(
)) ))
} }
oldBlock.contentHash != newBlock.contentHash -> { oldBlock.contentHash != newBlock.contentHash -> {
// Block content changed
changes.add(BlockChange( changes.add(BlockChange(
blockId = id, blockId = id,
type = BlockChangeType.MODIFIED, type = BlockChangeType.MODIFIED,
@@ -82,6 +133,7 @@ class BlockStore(
newHash = newBlock.contentHash newHash = newBlock.contentHash
)) ))
} }
// If hashes are the same, no change needed
} }
} }
@@ -100,20 +152,36 @@ class BlockStore(
return changes return changes
} }
/**
* Stores a block snapshot to disk with time-based organization
*
* Creates directory structure: yyyy/mm/dd/blocks-HH-MM-SS-filename.json
* This organization makes it easy to find snapshots by date and time
*/
private fun storeBlockSnapshot(snapshot: BlockSnapshot) { private fun storeBlockSnapshot(snapshot: BlockSnapshot) {
val datePath = getDatePath(Instant.parse(snapshot.timestamp)) val datePath = getDatePath(Instant.parse(snapshot.timestamp))
val timeString: String = getTimeString(Instant.parse(snapshot.timestamp)) val timeString: String = getTimeString(Instant.parse(snapshot.timestamp))
// Ensure the date directory exists
Files.createDirectories(blocksDir.resolve(datePath)) Files.createDirectories(blocksDir.resolve(datePath))
// Create the filename with timestamp and sanitized file path
val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json" val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json"
val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename) val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename)
// Write the snapshot as JSON
Files.writeString(snapshotPath, json.encodeToString(snapshot)) Files.writeString(snapshotPath, json.encodeToString(snapshot))
} }
/**
* Reconstructs full Block objects from a BlockSnapshot
*
* This involves looking up the actual content from the object store
* using the content hashes stored in the snapshot
*/
private fun reconstructBlocks(snapshot: BlockSnapshot): List<Block> { private fun reconstructBlocks(snapshot: BlockSnapshot): List<Block> {
return snapshot.blocks.map { blockState -> return snapshot.blocks.map { blockState ->
// Retrieve the actual content using the hash
val content = objectStore.getContent(blockState.contentHash) val content = objectStore.getContent(blockState.contentHash)
?: throw IllegalStateException("Missing content for block ${blockState.id}") ?: throw IllegalStateException("Missing content for block ${blockState.id}")
@@ -127,14 +195,18 @@ class BlockStore(
} }
} }
/**
* Finds the most recent snapshot for a given file.
*
* Walks through all time directories to find the latest snapshot
* for the specified file.
*/
fun getLatestBlockSnapshot(filePath: String): BlockSnapshot? { fun getLatestBlockSnapshot(filePath: String): BlockSnapshot? {
// Implementation to find latest snapshot for file
// Walk through time directories and find most recent
if (!blocksDir.exists()) return null if (!blocksDir.exists()) return null
// Walk through all time directories to find snapshot for this file
val snapshots = mutableListOf<Pair<BlockSnapshot, Instant>>() val snapshots = mutableListOf<Pair<BlockSnapshot, Instant>>()
// Walk through all snapshot files
Files.walk(blocksDir) Files.walk(blocksDir)
.filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") }
.filter { filePath.replace("/","_") in it.fileName.toString()} .filter { filePath.replace("/","_") in it.fileName.toString()}
@@ -146,15 +218,21 @@ class BlockStore(
snapshots.add(snapshot to timestamp) snapshots.add(snapshot to timestamp)
} }
catch (e: Exception) { catch (e: Exception) {
// Skip corrupted snapshots // Skip corrupted snapshots - they won't break the system
} }
} }
// Return the most recent snapshot // Return the most recent snapshot
return snapshots return snapshots
.maxByOrNull { it.second } .maxByOrNull { it.second }
?.first ?.first
} }
/**
* Finds the most recent snapshot for a file before a given timestamp.
*
* This is used for time-travel queries - "show me how this file looked at a specific point in time"
*/
fun getLatestBlockSnapshotBefore(filePath: String, timestamp: Instant): BlockSnapshot? { fun getLatestBlockSnapshotBefore(filePath: String, timestamp: Instant): BlockSnapshot? {
if (!blocksDir.exists()) return null if (!blocksDir.exists()) return null
@@ -180,11 +258,15 @@ class BlockStore(
// Return the most recent snapshot before the timestamp // Return the most recent snapshot before the timestamp
return snapshots return snapshots
.maxByOrNull { it.second } .maxByOrNull { it.second }
?.first ?.first
} }
// Get all snapshots for specific file /**
* Gets all snapshots for a specific file, ordered by recency.
*
* Useful for showing the complete history of a file.
*/
fun getSnapshotForFile(filePath: String): List<BlockSnapshot> { fun getSnapshotForFile(filePath: String): List<BlockSnapshot> {
if (!blocksDir.exists()) return emptyList() if (!blocksDir.exists()) return emptyList()
@@ -206,11 +288,15 @@ class BlockStore(
} }
return snapshots return snapshots
.sortedByDescending { it.second } .sortedByDescending { it.second } // Most recent first
.map { it.first } .map { it.first }
} }
// Check if any snapshots exist for a file /**
* Checks if any snapshots exist for a file
*
* Quick way to determine if a file is being tracked.
*/
fun hasSnapshots(filePath: String): Boolean { fun hasSnapshots(filePath: String): Boolean {
if (!blocksDir.exists()) return false if (!blocksDir.exists()) return false
@@ -219,7 +305,11 @@ class BlockStore(
.anyMatch { filePath.replace("/", "_") in it.fileName.toString() } .anyMatch { filePath.replace("/", "_") in it.fileName.toString() }
} }
// Get all files that have snapshots /**
* Gets a list of all files that have snapshots.
*
* Useful for showing what files are being tracked by the system.
*/
fun getTrackedFiles(): List<String> { fun getTrackedFiles(): List<String> {
if (!blocksDir.exists()) return emptyList() if (!blocksDir.exists()) return emptyList()
@@ -241,43 +331,79 @@ class BlockStore(
return files.toList() return files.toList()
} }
/**
* Converts a timestamp to a date-based directory path.
* Format: yyyy/mm/dd
*/
private fun getDatePath(timestamp: Instant): String { private fun getDatePath(timestamp: Instant): String {
val date = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault()) val date = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault())
return "${date.year}/${date.monthValue.toString().padStart(2, '0')}/${date.dayOfMonth.toString().padStart(2, '0')}" return "${date.year}/${date.monthValue.toString().padStart(2, '0')}/${date.dayOfMonth.toString().padStart(2, '0')}"
} }
/**
* Converts a timestamp to a time-based filename component.
* Format: HH-MM-SS
*/
private fun getTimeString(timestamp: Instant): String { private fun getTimeString(timestamp: Instant): String {
val time = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault()) val time = java.time.LocalDateTime.ofInstant(timestamp, java.time.ZoneId.systemDefault())
return "${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}-${time.second.toString().padStart(2, '0')}" return "${time.hour.toString().padStart(2, '0')}-${time.minute.toString().padStart(2, '0')}-${time.second.toString().padStart(2, '0')}"
} }
} }
/**
* Represents a snapshot of all blocks in a specific file at a specific point in time.
*
* This is the persistent format stored on disk. It contains metadata about
* the blocks but not their actual content (which is stored separately in
* the object store for deduplication)
*/
@Serializable @Serializable
data class BlockSnapshot( data class BlockSnapshot(
val filePath: String, val filePath: String, // Path of the file this snapshot represents
val timestamp: String, val timestamp: String, // When this snapshot was created (ISO format)
val blocks: List<BlockState>, val blocks: List<BlockState>, // The block states at this time
val frontMatter: FrontMatter? val frontMatter: FrontMatter? // Front matter if present
) )
/**
* Represents the state of a single block at a point in time
*
* This is a lightweight representation that stores metadata and a hash
* reference to the actual content, rather than deduplicating the content
*/
@Serializable @Serializable
data class BlockState( data class BlockState(
val id: String, val id: String, // Stable block identifier
val heading: String, val heading: String, // The heading text
val contentHash: String, val contentHash: String, // Hash reference to content in object store
val type: BlockType, val type: BlockType, // Type of block
val order: Int val order: Int // Position within file
) )
/**
* Represents a change to a block between two snapshots
*
* Used for generating diffs and understanding what changed between versions.
*/
@Serializable @Serializable
data class BlockChange( data class BlockChange(
val blockId: String, val blockId: String, // Which block changed
val type: BlockChangeType, val type: BlockChangeType, // Type of change
val heading: String, val heading: String, // Heading for display purposes
val oldHash: String? = null, val oldHash: String? = null, // Previous content hash (for modified/deleted)
val newHash: String? = null val newHash: String? = null // New content hash (for added/modified)
) )
/**
* Types of changes that can occur to blocks
*/
enum class BlockChangeType { enum class BlockChangeType {
ADDED, MODIFIED, DELETED /** Block was added in the new snapshot */
ADDED,
/** Block content was modified */
MODIFIED,
/** Block was removed in the new snapshot */
DELETED
} }

View File

@@ -1,34 +1,61 @@
package org.notevc.core package org.notevc.core
import java.nio.file.Files
import org.notevc.utils.HashUtils import org.notevc.utils.HashUtils
import java.nio.file.Path
import java.io.ByteArrayOutputStream import java.io.ByteArrayOutputStream
import java.util.zip.GZIPOutputStream import java.nio.file.Files
import java.nio.file.Path
import java.util.zip.GZIPInputStream import java.util.zip.GZIPInputStream
import kotlin.io.path.* import java.util.zip.GZIPOutputStream
import kotlin.io.path.exists
import kotlin.io.path.fileSize
import kotlin.io.path.isRegularFile
/**
* Content-addressable storage system for file content.
*
* The ObjectStore implements a Git-like storage system where:
* - Content is stored once and referenced by its SHA-256 hash
* - Identical content is automatically deduplicated
* - Large content is compressed to save space
* - Objects are organized in a two-level directory structure for performance
*
* Storage structure: objects/ab/cdef123... (first 2 chars as directory)
*/
class ObjectStore(private val objectsDir: Path) { class ObjectStore(private val objectsDir: Path) {
companion object { companion object {
/** Whether to enable GZIP compression for stored content */
private const val COMPRESSION_ENABLED = true private const val COMPRESSION_ENABLED = true
/** Minimum size in bytes before compression is applied */
private const val MIN_COMPRESSION_SIZE = 100 // bytes private const val MIN_COMPRESSION_SIZE = 100 // bytes
} }
// Store content and return its hash /**
// Uses git-like storage: objects/ab/cdef123... (first 2 characters as directory) * Stores content and returns its SHA-256 hash.
// Content is compressed if it exceeds MIN_COMPRESSION_SIZE *
* The storage process:
* 1. Calculate SHA-256 hash of the content
* 2. Check if content already exists (deduplication)
* 3. If new, compress if large enough and store in hash-based path
* 4. Return the hash for future reference
*
* @param content The string content to store
* @return SHA-256 hash that can be used to retrieve the content
*/
fun storeContent(content: String): String { fun storeContent(content: String): String {
val hash = HashUtils.sha256(content) val hash = HashUtils.sha256(content)
val objectPath = getObjectPath(hash) val objectPath = getObjectPath(hash)
// Only store if it doesn't already exist // Only store if it doesn't already exist (automatic deduplication)
if (!objectPath.exists()) { if (!objectPath.exists()) {
// Ensure the parent directory exists
Files.createDirectories(objectPath.parent) Files.createDirectories(objectPath.parent)
// Compress large content to save space
if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) { if (COMPRESSION_ENABLED && content.length > MIN_COMPRESSION_SIZE) {
val compressed = compressString(content) Files.write(objectPath, compressString(content))
Files.write(objectPath, compressed)
} else { } else {
// Store small content uncompressed for faster access
Files.writeString(objectPath, content) Files.writeString(objectPath, content)
} }
} }
@@ -36,7 +63,15 @@ class ObjectStore(private val objectsDir: Path) {
return hash return hash
} }
// Retrieve content by hash /**
* Retrieves content by its hash.
*
* Automatically detects whether content is compressed and handles
* decompression transparently.
*
* @param hash The SHA-256 hash of the content to retrieve
* @return The original content string, or null if not found
*/
fun getContent(hash: String): String? { fun getContent(hash: String): String? {
val objectPath = getObjectPath(hash) val objectPath = getObjectPath(hash)
if (!objectPath.exists()) return null if (!objectPath.exists()) return null
@@ -44,23 +79,32 @@ class ObjectStore(private val objectsDir: Path) {
return try { return try {
val bytes = Files.readAllBytes(objectPath) val bytes = Files.readAllBytes(objectPath)
// Try to decompress first, fall back to plain text // Auto-detect compression by checking GZIP magic bytes
try { try {
if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) { if (COMPRESSION_ENABLED && bytes.size > 2 && bytes[0] == 0x1f.toByte() && bytes[1] == 0x8b.toByte()) {
// This is a GZIP file (magic bytes: 0x1f 0x8b) // This is a GZIP file (magic bytes: 0x1f 0x8b)
decompressString(bytes) decompressString(bytes)
} else { } else {
// Treat as plain text
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} }
} catch (e: Exception) { } catch (e: Exception) {
// If decompression fails, try as plain text // If decompression fails, fall back to plain text
// This handles edge cases and format changes gracefully
String(bytes, Charsets.UTF_8) String(bytes, Charsets.UTF_8)
} }
} catch (e: Exception) { } catch (e: Exception) {
// Return null for any IO errors - content is missing or corrupted
null null
} }
} }
/**
* Compress a string using GZIP compression.
*
* @param content The string to compress
* @return Compressed bytes
*/
private fun compressString(content: String): ByteArray { private fun compressString(content: String): ByteArray {
val outputStream = ByteArrayOutputStream() val outputStream = ByteArrayOutputStream()
GZIPOutputStream(outputStream).use { gzip -> GZIPOutputStream(outputStream).use { gzip ->
@@ -69,6 +113,12 @@ class ObjectStore(private val objectsDir: Path) {
return outputStream.toByteArray() return outputStream.toByteArray()
} }
/**
* Decompresses GZIP-compressed bytes back to a string.
*
* @param compressed The compressed bytes
* @return The original string content
*/
private fun decompressString(compressed: ByteArray): String { private fun decompressString(compressed: ByteArray): String {
return GZIPInputStream(compressed.inputStream()).use { gzip -> return GZIPInputStream(compressed.inputStream()).use { gzip ->
gzip.readBytes().toString(Charsets.UTF_8) gzip.readBytes().toString(Charsets.UTF_8)

View File

@@ -1,25 +1,66 @@
package org.notevc.core package org.notevc.core
import java.nio.file.Path import kotlinx.serialization.Serializable
import java.nio.file.Paths
import kotlin.io.path.*
import org.notevc.BuildConfig
import kotlinx.serialization.*
import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import org.notevc.BuildConfig
import java.nio.file.Files import java.nio.file.Files
import java.nio.file.Path
import java.time.Instant import java.time.Instant
import kotlin.io.path.exists
import kotlin.io.path.isDirectory
import kotlin.io.path.isWritable
/**
* Represents a NoteVC repository and provides factory methods for creation and discovery.
*
* A Repository is the root container for all version control operations. It manages:
* - Repository initialization and discovery
* - Acess to the underlying storage systems (ObjectStore, BlockStore, etc.)
* - Repository metadata and configuration
*
* The repository structure:
* ```
* * project-root/
* └── .notevc/
* ├── metadata.json # Repository metadata
* ├── timeline.json # Commit history
* ├── objects/ # Content storage
* └── blocks/ # Block snapshots
* ```
*/
class Repository private constructor(private val rootPath: Path) { class Repository private constructor(private val rootPath: Path) {
/** The .notevc directory containing all version control data */
private val notevcDir = rootPath.resolve(NOTEVC_DIR) private val notevcDir = rootPath.resolve(NOTEVC_DIR)
/** Object store for content-addressable storage */
private val objectStore = ObjectStore(notevcDir.resolve("objects")) private val objectStore = ObjectStore(notevcDir.resolve("objects"))
/** The root path of this repository */
val path: Path get() = rootPath val path: Path get() = rootPath
/** Whether this repository has been initialized */
val isInitialized: Boolean get() = notevcDir.exists() val isInitialized: Boolean get() = notevcDir.exists()
companion object { companion object {
// Factory methods - these create Repository instances /** Name of the version control directory */
const val NOTEVC_DIR = ".notevc"
// Create repository at a specified path
/** Current version from build configuration */
val VERSION = BuildConfig.VERSION
/** Build timestamp from build configuration */
val BUILD_TIME = BuildConfig.BUILD_TIME
/**
* Creates a repository instance at the specified path
*
* This method validates that the path exists, is a directory,
* and is writeable before creating the repository instance.
*
* @param path The directory path where the repository should be located
* @return Result containing the Repository or an error
*/
fun at(path: String): Result<Repository> { fun at(path: String): Result<Repository> {
return try { return try {
val absolutePath = Path.of(path).toAbsolutePath() val absolutePath = Path.of(path).toAbsolutePath()
@@ -35,49 +76,69 @@ class Repository private constructor(private val rootPath: Path) {
} }
} }
// Create repository at current directory /**
* Creates a repository instance in the current working directory.
*
* @return Repository instance for the current directory.
*/
fun current(): Repository = Repository(Path.of(System.getProperty("user.dir")).toAbsolutePath()) fun current(): Repository = Repository(Path.of(System.getProperty("user.dir")).toAbsolutePath())
// Find existing repository by walking up /**
* Finds an existing repository by walking up the directory tree.
*
* Starting from the current directory, this method walks up the
* directory hiearchy looking for a .notevc directory, similar
* to how Git finds repositories.
*
* @return Repository instance if found, null if no repository found
*/
fun find(): Repository? { fun find(): Repository? {
var current = Path.of(System.getProperty("user.dir")).toAbsolutePath() var current = Path.of(System.getProperty("user.dir")).toAbsolutePath()
while (current != null) { while (current != null) {
if (current.resolve(NOTEVC_DIR).exists()) return Repository(current) if (current.resolve(NOTEVC_DIR).exists()) return Repository(current)
current = current.parent // Go up one level current = current.parent // Walk up one level
} }
return null // No repository found return null // No repository found in any parent directory
} }
// Constants
const val NOTEVC_DIR = ".notevc"
val VERSION = BuildConfig.VERSION
val BUILD_TIME = BuildConfig.BUILD_TIME
} }
/**
* String representation showing the repository path and initialization status.
*/
override fun toString(): String = "Repository(path=${rootPath.toAbsolutePath()}, initialized=$isInitialized)" override fun toString(): String = "Repository(path=${rootPath.toAbsolutePath()}, initialized=$isInitialized)"
/**
* Initiaizes a new repository in the current directory.
*
* The initialization process:
* 1. Checks if repository is already initialized
* 2. Creates the .notevc directory structure
* 3. Creates initial metadata with version and creation time
* 4. Creates empty timeline for commit history
*
* @return Result indicating success or failure with error details
*/
fun init(): Result<Unit> { fun init(): Result<Unit> {
return try { return try {
// Check if already initialized // Prevent double initialization
if (isInitialized) return Result.failure(Exception("Repository already initialized at ${rootPath.toAbsolutePath()}")) if (isInitialized) return Result.failure(Exception("Repository already initialized at ${rootPath.toAbsolutePath()}"))
// Create .notevc directory structure // Create the directory structure
Files.createDirectories(notevcDir) Files.createDirectories(notevcDir)
Files.createDirectories(notevcDir.resolve("objects")) Files.createDirectories(notevcDir.resolve("objects"))
// Create initial metadata // Create initial repository metadata
val metadata = RepoMetadata( val metadata = RepoMetadata(
version = VERSION, version = VERSION,
created = Instant.now().toString(), created = Instant.now().toString(),
head = null head = null // No commits yet
) )
// Save metadata to .notevc/metadata.json // Save metadata to .notevc/metadata.json
val metadataFile = notevcDir.resolve("metadata.json") val metadataFile = notevcDir.resolve("metadata.json")
Files.writeString(metadataFile, Json.encodeToString(metadata)) Files.writeString(metadataFile, Json.encodeToString(metadata))
// Create empty timeline // Create empty timeline for commit history
val timelineFile = notevcDir.resolve("timeline.json") val timelineFile = notevcDir.resolve("timeline.json")
Files.writeString(timelineFile, "[]") Files.writeString(timelineFile, "[]")
@@ -89,35 +150,59 @@ class Repository private constructor(private val rootPath: Path) {
} }
} }
/**
* Repository metadata stored in .notevc/metadata.json
*
* Contains essential information about the repository including
* version, creation time, current HEAD, and configuration.
*/
@Serializable @Serializable
data class RepoMetadata( data class RepoMetadata(
val version: String, val version: String, // NoteVC version that created this repo
val created: String, val created: String, // ISO timestamp of creation
var head: String?, var head: String?, // Hash of the current HEAD commit
val config: RepoConfig = RepoConfig(), val config: RepoConfig = RepoConfig(), // Repository configuration
val lastCommit: CommitInfo? = null val lastCommit: CommitInfo? = null // Information about the last commit
) )
/**
* Information about a specific commit.
*
* Used in repository metadata to track the most recent commit
* without having to parse the entire timeline.
*/
@Serializable @Serializable
data class CommitInfo( data class CommitInfo(
val hash: String, val hash: String, // Unique commit identifier
val message: String, val message: String, // Commit message
val timestamp: String, val timestamp: String, // When the commit was made
val author: String val author: String // Who made the commit
) )
/**
* Repository configuration options.
*
* These settings control how the repository behaves during
* various operations.
*/
@Serializable @Serializable
data class RepoConfig( data class RepoConfig(
val autoCommit: Boolean = false, val autoCommit: Boolean = false, // Whether to automatically commit changes
val compressionEnabled: Boolean = false, val compressionEnabled: Boolean = false, // Whether to compress stored content
val maxSnapshots: Int = 100 val maxSnapshots: Int = 100 // Maximum number of snapshots to keep
) )
/**
* Represents a single commit in the repository timeline.
*
* Each commit represents a point in time when changes were
* recorded to the repository.
*/
@Serializable @Serializable
data class CommitEntry( data class CommitEntry(
val hash: String, val hash: String, // Unique commit identifier
val message: String, val message: String, // Commit message describing changes
val timestamp: String, val timestamp: String, // ISO timestamp of when the commit was made
val author: String, val author: String, // Author of the commit
val parent: String? = null val parent: String? = null // Hash of the parent commit (null for initial commit)
) )