diff --git a/.notevc/metadata.json b/.notevc/metadata.json new file mode 100644 index 0000000..0424d93 --- /dev/null +++ b/.notevc/metadata.json @@ -0,0 +1 @@ +{"version":"1.0.0","created":"2025-11-06T22:16:55.863743Z","head":null} \ No newline at end of file diff --git a/.notevc/timeline.json b/.notevc/timeline.json new file mode 100644 index 0000000..0637a08 --- /dev/null +++ b/.notevc/timeline.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/CHECKLIST.md b/CHECKLIST.md index c11a53a..ad9e24c 100644 --- a/CHECKLIST.md +++ b/CHECKLIST.md @@ -8,36 +8,36 @@ - [x] Create `Repository.kt` class - [x] Implement `.notevc` directory initialization -- [ ] Create `ObjectStore.kt` for content storage -- [ ] Implement content hashing `HashUtils.kt` -- [ ] Create `NoteSnapshot` data class -- [ ] Implement `Timeline.kt` for version tracking -- [ ] Add `RepoMetadata` and configuration +- [x] Create `ObjectStore.kt` for content storage +- [x] Implement content hashing `HashUtils.kt` +- [x] Create `NoteSnapshot` data class +- [x] Implement `Timeline.kt` for version tracking +- [x] Add `RepoMetadata` and configuration # File Operations -- [ ] Implement markdown file scanning -- [ ] Create file change detection logic -- [ ] Add file content reading/writing utilities -- [ ] Implement path resolution and validation -- [ ] Add file timestamp tracking +- [x] Implement markdown file scanning +- [x] Create file change detection logic +- [x] Add file content reading/writing utilities +- [x] Implement path resolution and validation +- [x] Add file timestamp tracking - [ ] Create backup and restore mechanisms # Core Commands ## Init Command -- [ ] `notevc init` - Initialize repository -- [ ] Create `.notevc` directory structure -- [ ] Generate initial metadata file -- [ ] Handle existing repository detection +- [x] `notevc init` - Initialize repository +- [x] Create `.notevc` directory structure +- [x] Generate initial metadata file +- [x] Handle existing repository detection ## Status Command -- [ ] `notevc status` - Show file changes -- [ ] Compare current files with last snapshot -- [ ] Display added/modified/deleted files -- [ ] Show clean working directory message +- [x] `notevc status` - Show file changes +- [x] Compare current files with last snapshot +- [x] Display added/modified/deleted files +- [x] Show clean working directory message ## Commit command diff --git a/src/main/kotlin/io/notevc/NoteVC.kt b/src/main/kotlin/io/notevc/NoteVC.kt index e07fd15..3e0c345 100644 --- a/src/main/kotlin/io/notevc/NoteVC.kt +++ b/src/main/kotlin/io/notevc/NoteVC.kt @@ -6,7 +6,7 @@ import io.notevc.commands.* fun main(args: Array) { // Args logic when (args.firstOrNull()) { - "init" -> { + "init", "i" -> { val initCommand = InitCommand() val result = initCommand.execute(args.getOrNull(1)) @@ -20,12 +20,22 @@ fun main(args: Array) { println("Not implemented yet") } - "status" -> { - println("Not implemented yet") + "status", "st" -> { + val statusCommand = StatusCommand() + val result = statusCommand.execute() + + result.fold( + onSuccess = { output -> println(output) }, + onFailure = { error -> println("Error: ${error.message}") } + ) } "version", "--version", "-v" -> { println("notevc version ${Repository.VERSION}") } + + else -> { + println("Usage: notevc init|commit|status|version") + } } } diff --git a/src/main/kotlin/io/notevc/commands/InitCommand.kt b/src/main/kotlin/io/notevc/commands/InitCommand.kt index 3ef238a..68cea60 100644 --- a/src/main/kotlin/io/notevc/commands/InitCommand.kt +++ b/src/main/kotlin/io/notevc/commands/InitCommand.kt @@ -12,7 +12,8 @@ class InitCommand { repo.init().fold( onSuccess = { - Result.success("Initialized notevc repository in ${repo.path.toAbsolutePath()}") + val absolutePath = repo.path.toAbsolutePath().toString() + Result.success("Initialized notevc repository in $absolutePath") }, onFailure = { error -> Result.failure(error) diff --git a/src/main/kotlin/io/notevc/commands/StatusCommand.kt b/src/main/kotlin/io/notevc/commands/StatusCommand.kt index a1f7d85..c9ff13b 100644 --- a/src/main/kotlin/io/notevc/commands/StatusCommand.kt +++ b/src/main/kotlin/io/notevc/commands/StatusCommand.kt @@ -1 +1,164 @@ package io.notevc.commands + +import io.notevc.core.* +import io.notevc.utils.FileUtils +import io.notevc.core.Repository.Companion.NOTEVC_DIR +import java.time.Instant + +class StatusCommand { + fun execute(): Result { + return try { + val repo = Repository.find() + ?: return Result.failure(Exception("Not in notevc repository. Run `notevc init` first.")) + + val status = getRepositoryStatus(repo) + val output = formatStatusOutput(status) + + Result.success(output) + } + catch (e: Exception) { + Result.failure(e) + } + } + + private fun getRepositoryStatus(repo: Repository): RepositoryStatus { + val objectStore = ObjectStore(repo.path.resolve("$NOTEVC_DIR/objects")) + val blockStore = BlockStore(objectStore, repo.path.resolve("$NOTEVC_DIR/blocks")) + val blockParser = BlockParser() + + // Find all markdown files in the repository + val currentFiles = FileUtils.findMarkdownFiles(repo.path) + val trackedFiles = blockStore.getTrackedFiles() + + val fileStatuses = mutableListOf() + + // Check tracked files (files that have previous snapshots) + trackedFiles.forEach { filePath -> + val fullPath = repo.path.resolve(filePath) + + // File exists - check for changes + val currentContent = java.nio.file.Files.readString(fullPath) + val parsedFile = blockParser.parseFile(currentContent, filePath) + + // Get latest snapshot for comparison + val latestSnapshot = blockStore.getLatestBlockSnapshot(filePath) + + if (latestSnapshot != null) { + // Create snapshot to compare + val currentSnapshot = createCurrentSnapshot(parsedFile, objectStore) + val changes = blockStore.compareBlocks(latestSnapshot, currentSnapshot) + + if (changes.isNotEmpty()) { + fileStatuses.add(FileStatus( + path = filePath, + type = FileStatusType.MODIFIED, + blockChanges = changes + )) + } + } else { + // File was deleted + fileStatuses.add(FileStatus( + path = filePath, + type = FileStatusType.DELETED + )) + } + } + + // Check for untracked files (new files) + currentFiles.forEach { filePath -> + val relativePath = repo.path.relativize(filePath).toString() + + if (relativePath !in trackedFiles) { + val content = java.nio.file.Files.readString(filePath) + val parsedFile = blockParser.parseFile(content, relativePath) + + fileStatuses.add(FileStatus( + path = relativePath, + type = FileStatusType.UNTRACKED, + blockCount = parsedFile.blocks.size, + )) + } + } + + return RepositoryStatus(fileStatuses) + } + + private fun createCurrentSnapshot(parsedFile: ParsedFile, objectStore: ObjectStore): BlockSnapshot { + return BlockSnapshot( + filePath = parsedFile.path, + timestamp = Instant.now().toString(), + blocks = parsedFile.blocks.map { block -> + BlockState( + id = block.id, + heading = block.heading, + contentHash = objectStore.storeContent(block.content), + type = block.type, + order = block.order + ) + }, + frontMatter = parsedFile.frontMatter + ) + } + + private fun formatStatusOutput(status: RepositoryStatus): String { + if (status.files.isEmpty()) return "Working directory clean - no changes detected" + + val output = StringBuilder() + val grouped = status.files.groupBy { it.type } + + // Modified files + grouped[FileStatusType.MODIFIED]?.let { modifiedFiles -> + output.appendLine("Modified files:") + modifiedFiles.forEach { fileStatus -> + output.appendLine(" ${fileStatus.path}") + fileStatus.blockChanges?.forEach { change -> + val symbol = when (change.type) { + BlockChangeType.MODIFIED -> "M" + BlockChangeType.ADDED -> "A" + BlockChangeType.DELETED -> "D" + } + val heading = change.heading.replace(Regex("^#+\\s*"), "").trim() + output.appendLine("―― $symbol $heading") + } + } + } + + // Untracked files + grouped[FileStatusType.UNTRACKED]?.let { untrackedFiles -> + output.appendLine("Untracked files:") + untrackedFiles.forEach { fileStatus -> + output.appendLine("―― ${fileStatus.path} (${fileStatus.blockCount} blocks)") + } + output.appendLine() + } + + // Deleted files + grouped[FileStatusType.DELETED]?.let { deletedFiles -> + output.appendLine("Deleted files:") + deletedFiles.forEach { fileStatus -> + output.appendLine("―― ${fileStatus.path}") + } + output.appendLine() + } + + return output.toString().trim() + } +} + +// Data classes for status information +data class RepositoryStatus( + val files: List +) + +data class FileStatus( + val path: String, + val type: FileStatusType, + val blockChanges: List? = null, + val blockCount: Int? = null +) + +enum class FileStatusType { + MODIFIED, // File has changes + UNTRACKED, // File was not yet commited + DELETED // File was removed +} diff --git a/src/main/kotlin/io/notevc/core/BlockParser.kt b/src/main/kotlin/io/notevc/core/BlockParser.kt new file mode 100644 index 0000000..87a7343 --- /dev/null +++ b/src/main/kotlin/io/notevc/core/BlockParser.kt @@ -0,0 +1,155 @@ +package io.notevc.core + +import kotlinx.serialization.Serializable + +class BlockParser { + // Parse markdown file into blocks based on headings + fun parseFile(content: String, filePath: String): ParsedFile { + val lines = content.lines() + val blocks = mutableListOf() + val frontMatter = extractFrontMatter(lines) + + var currentBlock: MutableList? = null + var currentHeading: String? = null + var blockIndex = 0 + + val contentLines = if (frontMatter != null) { + lines.drop(frontMatter.endLine + 1) + } else lines + + for (line in contentLines) { + when { + line.startsWith("#") -> { + // Save previous block if exists + if (currentBlock != null && currentHeading != null) { + blocks.add(Block( + id = generateBlockId(filePath, currentHeading, blockIndex), + heading = currentHeading, + content = currentBlock.joinToString("\n"), + type = BlockType.HEADING_SECTION, + order = blockIndex++ + )) + } + + // Start new block + currentHeading = line + currentBlock = mutableListOf(line) + } + else -> { + // Add to current block or create content-only block + if (currentBlock != null) currentBlock.add(line) + else { + // Content before any heading + currentBlock = mutableListOf() + currentHeading = "" + currentBlock.add(line) + } + } + } + } + + // Save final block + if (currentBlock != null && currentHeading != null) { + blocks.add(Block( + id = generateBlockId(filePath, currentHeading, blockIndex), + heading = currentHeading, + content = currentBlock.joinToString("\n"), + type = BlockType.HEADING_SECTION, + order = blockIndex + )) + } + + return ParsedFile( + path = filePath, + frontMatter = frontMatter, + blocks = blocks + ) + } + + // Extract YAML front matter + private fun extractFrontMatter(lines: List): FrontMatter? { + if (lines.isEmpty() || lines[0] != "---") return null + + val endIndex = lines.drop(1).indexOfFirst { it == "---"} + if (endIndex == -1) return null + + val yamlLines = lines.subList(1, endIndex + 1) + val properties = mutableMapOf() + + yamlLines.forEach { line -> + val colonIndex = line.indexOf(":") + if (colonIndex != -1) { + val key = line.take(colonIndex).trim() + val value = line.substring(colonIndex + 1).trim().removeSurrounding("\"") + properties[key] = value + } + } + + return FrontMatter( + properties = properties, + endLine = endIndex + 1 + ) + } + + // Generate stable block id + private fun generateBlockId(filePath: String, heading: String, order: Int): String { + val cleanHeading = heading.replace(Regex("^#+\\s*"), "").trim() + val baseId = "$filePath:$cleanHeading:$order" + return io.notevc.utils.HashUtils.sha256(baseId).take(12) + } + + // Reconstruct file from blocks + fun reconstructFile(parsedFile: ParsedFile): String { + val result = StringBuilder() + + // Add front matter if exists + parsedFile.frontMatter?.let { fm -> + result.appendLine("---") + fm.properties.forEach { (key, value) -> + result.appendLine("$key: \"$value\"") + } + result.appendLine("---") + result.appendLine() + } + + // Add blocks in order + parsedFile.blocks.sortedBy { it.order }.forEach { block -> + result.appendLine(block.content) + if (block != parsedFile.blocks.last()) { + result.appendLine() + } + } + + return result.toString() + } +} + +@Serializable +data class ParsedFile( + val path: String, + val frontMatter: FrontMatter?, + val blocks: List +) + +@Serializable +data class Block( + val id: String, // Stable block identifier + val heading: String, // The heading text + val content: String, // Full block content including heading + val type: BlockType, + val order: Int // Order within file +) + +@Serializable +data class FrontMatter( + val properties: Map, + val endLine: Int +) { + val isEnabled: Boolean get() = properties["enabled"]?.lowercase() == "true" + val isAutomatic: Boolean get() = properties["automatic"]?.lowercase() == "true" +} + +enum class BlockType { + HEADING_SECTION, // # Heading with content + CONTENT_ONLY // Content without heading +} diff --git a/src/main/kotlin/io/notevc/core/BlockStore.kt b/src/main/kotlin/io/notevc/core/BlockStore.kt new file mode 100644 index 0000000..60e7579 --- /dev/null +++ b/src/main/kotlin/io/notevc/core/BlockStore.kt @@ -0,0 +1,283 @@ +package io.notevc.core + +import io.notevc.utils.HashUtils +import kotlinx.serialization.Serializable +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import kotlin.io.path.* + +class BlockStore( + private val objectStore: ObjectStore, + private val blocksDir: Path +) { + private val json = Json { prettyPrint = true } + + // Store blocks from a parsed file and return block snapshot + fun storeBlocks(parsedFile: ParsedFile, timestamp: Instant): BlockSnapshot { + val blockStates = parsedFile.blocks.map { block -> + val contentHash = objectStore.storeContent(block.content) + + BlockState( + id = block.id, + heading = block.heading, + contentHash = contentHash, + type = block.type, + order = block.order + ) + } + + val snapshot = BlockSnapshot( + filePath = parsedFile.path, + timestamp = timestamp.toString(), + blocks = blockStates, + frontMatter = parsedFile.frontMatter + ) + + // Store block snapshot with time-based structure (yyyy/mm/dd) + storeBlockSnapshot(snapshot) + + return snapshot + } + + // Get blocks for a file at a specific time + fun getBlocksAtTime(filePath: String, timestamp: Instant): List? { + val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp) + return snapshot?.let { reconstructBlocks(it) } + } + + // Get current blocks for a file + fun getCurrentBlocks(filePath: String): List? { + val snapshot = getLatestBlockSnapshot(filePath) + return snapshot?.let { reconstructBlocks(it) } + } + + // Compare blocks between two snapshots + fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot): List { + val changes = mutableListOf() + + val oldBlocks = oldSnapshot?.blocks?.associateBy { it.id } ?: emptyMap() + val newBlocks = newSnapshot.blocks.associateBy { it.id } + + // Find added and modified blocks + newBlocks.forEach { (id, newBlock) -> + val oldBlock = oldBlocks[id] + when { + oldBlock == null -> { + changes.add(BlockChange( + blockId = id, + type = BlockChangeType.ADDED, + heading = newBlock.heading, + newHash = newBlock.contentHash + )) + } + oldBlock.contentHash != newBlock.contentHash -> { + changes.add(BlockChange( + blockId = id, + type = BlockChangeType.MODIFIED, + heading = newBlock.heading, + oldHash = oldBlock.contentHash, + newHash = newBlock.contentHash + )) + } + } + } + + // Find deleted blocks + oldBlocks.forEach { (id, oldBlock) -> + if (id !in newBlocks) { + changes.add(BlockChange( + blockId = id, + type = BlockChangeType.DELETED, + heading = oldBlock.heading, + oldHash = oldBlock.contentHash + )) + } + } + + return changes + } + + private fun storeBlockSnapshot(snapshot: BlockSnapshot) { + val datePath = getDatePath(Instant.parse(snapshot.timestamp)) + val timeString: String = getTimeString(Instant.parse(snapshot.timestamp)) + + Files.createDirectories(blocksDir.resolve(datePath)) + + val filename = "blocks-$timeString-${snapshot.filePath.replace("/","_")}.json" + val snapshotPath: Path = blocksDir.resolve(datePath).resolve(filename) + + Files.writeString(snapshotPath, json.encodeToString(snapshot)) + } + + private fun reconstructBlocks(snapshot: BlockSnapshot): List { + return snapshot.blocks.map { blockState -> + val content = objectStore.getContent(blockState.contentHash) + ?: throw IllegalStateException("Missing content for block ${blockState.id}") + + Block( + id = blockState.id, + heading = blockState.heading, + content = content, + type = blockState.type, + order = blockState.order + ) + } + } + + 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 + + // Walk through all time directories to find snapshot for this file + val snapshots = mutableListOf>() + + Files.walk(blocksDir) + .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } + .filter { filePath.replace("/","_") in it.fileName.toString()} + .forEach { snapshotFile -> + try { + val content = Files.readString(snapshotFile) + val snapshot = json.decodeFromString(content) + val timestamp = Instant.parse(snapshot.timestamp) + snapshots.add(snapshot to timestamp) + } + catch (e: Exception) { + // Skip corrupted snapshots + } + } + // Return the most recent snapshot + return snapshots + .maxByOrNull { it.second } + ?.first + } + + fun getLatestBlockSnapshotBefore(filePath: String, timestamp: Instant): BlockSnapshot? { + if (!blocksDir.exists()) return null + + val snapshots = mutableListOf>() + + Files.walk(blocksDir) + .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } + .filter { filePath.replace("/","_") in it.fileName.toString()} + .forEach { snapshotFile -> + try { + val content = Files.readString(snapshotFile) + val snapshot = json.decodeFromString(content) + val snapshotTime = Instant.parse(snapshot.timestamp) + + // Only include snapshots before the given timestamp + if (snapshotTime.isBefore(timestamp)) { + snapshots.add(snapshot to snapshotTime) + } + } catch (e: Exception) { + // Skip corrupted snapshots + } + } + + // Return the most recent snapshot before the timestamp + return snapshots + .maxByOrNull { it.second } + ?.first + } + + // Get all snapshots for specific file + fun getSnapshotForFile(filePath: String): List { + if (!blocksDir.exists()) return emptyList() + + val snapshots = mutableListOf>() + + Files.walk(blocksDir) + .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } + .filter { filePath.replace("/","_") in it.fileName.toString() } + .forEach { snapshotFile -> + try { + val content = Files.readString(snapshotFile) + val snapshot = json.decodeFromString(content) + val timestamp = Instant.parse(snapshot.timestamp) + snapshots.add(snapshot to timestamp) + } + catch (e: Exception) { + // Skip corrupted snapshots + } + } + + return snapshots + .sortedByDescending { it.second } + .map { it.first } + } + + // Check if any snapshots exist for a file + fun hasSnapshots(filePath: String): Boolean { + if (!blocksDir.exists()) return false + + return Files.walk(blocksDir) + .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } + .anyMatch { filePath.replace("/", "_") in it.fileName.toString() } + } + + // Get all files that have snapshots + fun getTrackedFiles(): List { + if (!blocksDir.exists()) return emptyList() + + val files = mutableSetOf() + + Files.walk(blocksDir) + .filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") } + .forEach { snapshotFile -> + try { + val content = Files.readString(snapshotFile) + val snapshot = json.decodeFromString(content) + files.add(snapshot.filePath) + } + catch (e: Exception) { + // Skip corrupted snapshots + } + } + + return files.toList() + } + + private fun getDatePath(timestamp: Instant): String { + 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')}" + } + + private fun getTimeString(timestamp: Instant): String { + 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')}" + } +} + +@Serializable +data class BlockSnapshot( + val filePath: String, + val timestamp: String, + val blocks: List, + val frontMatter: FrontMatter? +) + +@Serializable +data class BlockState( + val id: String, + val heading: String, + val contentHash: String, + val type: BlockType, + val order: Int +) + +@Serializable +data class BlockChange( + val blockId: String, + val type: BlockChangeType, + val heading: String, + val oldHash: String? = null, + val newHash: String? = null +) + +enum class BlockChangeType { + ADDED, MODIFIED, DELETED +} diff --git a/src/main/kotlin/io/notevc/core/ObjectStore.kt b/src/main/kotlin/io/notevc/core/ObjectStore.kt index 06fd659..ed77a19 100644 --- a/src/main/kotlin/io/notevc/core/ObjectStore.kt +++ b/src/main/kotlin/io/notevc/core/ObjectStore.kt @@ -1 +1,74 @@ package io.notevc.core + +import java.nio.file.Files +import io.notevc.utils.HashUtils +import java.nio.file.Path +import kotlin.io.path.* + +class ObjectStore(private val objectsDir: Path) { + // Store content and return its hash + // Uses git-like storage: objects/ab/cdef123... (first 2 characters as directory) + fun storeContent(content: String): String { + val hash = HashUtils.sha256(content) + val objectPath = getObjectPath(hash) + + // Only store if it doesn't already exist + if (!objectPath.exists()) { + Files.createDirectories(objectPath.parent) + Files.writeString(objectPath, content) + } + + return hash + } + + // Retrieve content by hash + fun getContent(hash: String): String? { + val objectPath = getObjectPath(hash) + return if (objectPath.exists()) { + Files.readString(objectPath) + } else null + } + + // Check if content exists + fun hasContent(hash: String): Boolean = getObjectPath(hash).exists() + + // Get all stored object hashes + fun getAllHashes(): List { + if (!objectsDir.exists()) return emptyList() + + return Files.walk(objectsDir, 2) + .filter { it.isRegularFile() } + .map { path -> + val parent = path.parent.fileName.toString() + val filename = path.fileName.toString() + parent + filename + } + .toList() + } + + // Get storage statistics + fun getStats(): ObjectStoreStats { + val hashes = getAllHashes() + val totalSize = hashes.sumOf { hash -> + getObjectPath(hash).fileSize() + } + + return ObjectStoreStats( + objectCount = hashes.size, + totalSize = totalSize + ) + } + + // Convert hash to file path + private fun getObjectPath(hash: String): Path { + require(hash.length >= 3) { "Hash too short: $hash" } + val dir = hash.take(2) + val filename = hash.drop(2) + return objectsDir.resolve(dir).resolve(filename) + } +} + +data class ObjectStoreStats( + val objectCount: Int, + val totalSize: Long +) diff --git a/src/main/kotlin/io/notevc/core/Repository.kt b/src/main/kotlin/io/notevc/core/Repository.kt index aec69a4..31bf56a 100644 --- a/src/main/kotlin/io/notevc/core/Repository.kt +++ b/src/main/kotlin/io/notevc/core/Repository.kt @@ -12,6 +12,7 @@ import java.time.Instant class Repository private constructor(private val rootPath: Path) { private val notevcDir = rootPath.resolve(NOTEVC_DIR) + private val objectStore = ObjectStore(notevcDir.resolve("objects")) val path: Path get() = rootPath val isInitialized: Boolean get() = notevcDir.exists() diff --git a/src/main/kotlin/io/notevc/utils/FileUtils.kt b/src/main/kotlin/io/notevc/utils/FileUtils.kt index 3f954f8..50ff311 100644 --- a/src/main/kotlin/io/notevc/utils/FileUtils.kt +++ b/src/main/kotlin/io/notevc/utils/FileUtils.kt @@ -1,3 +1,83 @@ package io.notevc.utils +import java.nio.file.Files +import java.nio.file.Path +import java.time.Instant +import kotlin.io.path.* +import io.notevc.core.Repository.Companion.NOTEVC_DIR +object FileUtils { + // Find all markdown files in directory (recursively) + fun findMarkdownFiles(rootPath: Path): List { + if (!rootPath.exists() || !rootPath.isDirectory()) return emptyList() + + return Files.walk(rootPath) + .filter { path -> + path.isRegularFile() && + path.extension.lowercase() == "md" && + !isInNotevcDir(path, rootPath) + } + .toList() + } + + // Get file state information + fun getFileState(filePath: Path, rootPath: Path): FileState { + val content = Files.readString(filePath) + val contentHash = HashUtils.sha256(content) + val relativePath = rootPath.relativize(filePath).toString() + + return FileState( + path = relativePath, + contentHash = contentHash, + size = filePath.fileSize(), + lastModified = filePath.getLastModifiedTime().toInstant() + ) + } + + // Get current state of all markdown files + fun getCurrentFileStates(rootPath: Path): Map { + return findMarkdownFiles(rootPath) + .associate { filePath -> + val fileState = getFileState(filePath, rootPath) + fileState.path to fileState + } + } + + // Create relative path string for display + fun getDisplayPath(filePath: Path, rootPath: Path): String = rootPath.relativize(filePath).toString() + + // Ensure directory exists + fun ensureDirectoryExists(path: Path) { + if (!path.exists()) { + Files.createDirectories(path) + } + } + + // Check if path is inside .notevc directory + private fun isInNotevcDir(filePath: Path, rootPath: Path): Boolean { + val relativePath = rootPath.relativize(filePath) + return relativePath.toString().startsWith(NOTEVC_DIR) + } +} + +// Represents the state of a file at a point in time +data class FileState( + val path: String, // Relative path from repo root + val contentHash: String, // SHA-256 of content + val size: Long, // File size in bytes + val lastModified: Instant // Last modification time +) + +// Represents a change to a file +data class FileChange( + val path: String, + val type: ChangeType, + val oldHash: String? = null, + val newHash: String? = null +) + +enum class ChangeType { + ADDED, // New file + MODIFIED, // Content changed + DELETED // File removed +} diff --git a/src/main/kotlin/io/notevc/utils/HashUtils.kt b/src/main/kotlin/io/notevc/utils/HashUtils.kt index 5675552..b3f8304 100644 --- a/src/main/kotlin/io/notevc/utils/HashUtils.kt +++ b/src/main/kotlin/io/notevc/utils/HashUtils.kt @@ -1 +1,24 @@ package io.notevc.utils + +import java.security.MessageDigest + +object HashUtils { + // Generate SHA-256 hash of string content + fun sha256(content: String): String { + val digest = MessageDigest.getInstance("SHA-256") + val hashBytes = digest.digest(content.toByteArray()) + return hashBytes.joinToString("") { "%02x" .format(it) } + } + + // Generate SHA-256 hash of file content + fun sha256File(filePath: java.nio.file.Path): String { + val content = java.nio.file.Files.readString(filePath) + return sha256(content) + } + + // Generate short hash (first 8 characters) for display + fun shortHash(hash: String): String = hash.take(8) + + // Validate hash format + fun isValidHash(hash: String): Boolean = hash.matches(Regex("^[a-f0-9]{64}$")) +}