feat(status): added status command and object storage
This commit is contained in:
1
.notevc/metadata.json
Normal file
1
.notevc/metadata.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
{"version":"1.0.0","created":"2025-11-06T22:16:55.863743Z","head":null}
|
||||||
1
.notevc/timeline.json
Normal file
1
.notevc/timeline.json
Normal file
@@ -0,0 +1 @@
|
|||||||
|
[]
|
||||||
36
CHECKLIST.md
36
CHECKLIST.md
@@ -8,36 +8,36 @@
|
|||||||
|
|
||||||
- [x] Create `Repository.kt` class
|
- [x] Create `Repository.kt` class
|
||||||
- [x] Implement `.notevc` directory initialization
|
- [x] Implement `.notevc` directory initialization
|
||||||
- [ ] Create `ObjectStore.kt` for content storage
|
- [x] Create `ObjectStore.kt` for content storage
|
||||||
- [ ] Implement content hashing `HashUtils.kt`
|
- [x] Implement content hashing `HashUtils.kt`
|
||||||
- [ ] Create `NoteSnapshot` data class
|
- [x] Create `NoteSnapshot` data class
|
||||||
- [ ] Implement `Timeline.kt` for version tracking
|
- [x] Implement `Timeline.kt` for version tracking
|
||||||
- [ ] Add `RepoMetadata` and configuration
|
- [x] Add `RepoMetadata` and configuration
|
||||||
|
|
||||||
# File Operations
|
# File Operations
|
||||||
|
|
||||||
- [ ] Implement markdown file scanning
|
- [x] Implement markdown file scanning
|
||||||
- [ ] Create file change detection logic
|
- [x] Create file change detection logic
|
||||||
- [ ] Add file content reading/writing utilities
|
- [x] Add file content reading/writing utilities
|
||||||
- [ ] Implement path resolution and validation
|
- [x] Implement path resolution and validation
|
||||||
- [ ] Add file timestamp tracking
|
- [x] Add file timestamp tracking
|
||||||
- [ ] Create backup and restore mechanisms
|
- [ ] Create backup and restore mechanisms
|
||||||
|
|
||||||
# Core Commands
|
# Core Commands
|
||||||
|
|
||||||
## Init Command
|
## Init Command
|
||||||
|
|
||||||
- [ ] `notevc init` - Initialize repository
|
- [x] `notevc init` - Initialize repository
|
||||||
- [ ] Create `.notevc` directory structure
|
- [x] Create `.notevc` directory structure
|
||||||
- [ ] Generate initial metadata file
|
- [x] Generate initial metadata file
|
||||||
- [ ] Handle existing repository detection
|
- [x] Handle existing repository detection
|
||||||
|
|
||||||
## Status Command
|
## Status Command
|
||||||
|
|
||||||
- [ ] `notevc status` - Show file changes
|
- [x] `notevc status` - Show file changes
|
||||||
- [ ] Compare current files with last snapshot
|
- [x] Compare current files with last snapshot
|
||||||
- [ ] Display added/modified/deleted files
|
- [x] Display added/modified/deleted files
|
||||||
- [ ] Show clean working directory message
|
- [x] Show clean working directory message
|
||||||
|
|
||||||
## Commit command
|
## Commit command
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import io.notevc.commands.*
|
|||||||
fun main(args: Array<String>) {
|
fun main(args: Array<String>) {
|
||||||
// Args logic
|
// Args logic
|
||||||
when (args.firstOrNull()) {
|
when (args.firstOrNull()) {
|
||||||
"init" -> {
|
"init", "i" -> {
|
||||||
val initCommand = InitCommand()
|
val initCommand = InitCommand()
|
||||||
val result = initCommand.execute(args.getOrNull(1))
|
val result = initCommand.execute(args.getOrNull(1))
|
||||||
|
|
||||||
@@ -20,12 +20,22 @@ fun main(args: Array<String>) {
|
|||||||
println("Not implemented yet")
|
println("Not implemented yet")
|
||||||
}
|
}
|
||||||
|
|
||||||
"status" -> {
|
"status", "st" -> {
|
||||||
println("Not implemented yet")
|
val statusCommand = StatusCommand()
|
||||||
|
val result = statusCommand.execute()
|
||||||
|
|
||||||
|
result.fold(
|
||||||
|
onSuccess = { output -> println(output) },
|
||||||
|
onFailure = { error -> println("Error: ${error.message}") }
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
"version", "--version", "-v" -> {
|
"version", "--version", "-v" -> {
|
||||||
println("notevc version ${Repository.VERSION}")
|
println("notevc version ${Repository.VERSION}")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
else -> {
|
||||||
|
println("Usage: notevc init|commit|status|version")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ class InitCommand {
|
|||||||
|
|
||||||
repo.init().fold(
|
repo.init().fold(
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
Result.success("Initialized notevc repository in ${repo.path.toAbsolutePath()}")
|
val absolutePath = repo.path.toAbsolutePath().toString()
|
||||||
|
Result.success("Initialized notevc repository in $absolutePath")
|
||||||
},
|
},
|
||||||
onFailure = {
|
onFailure = {
|
||||||
error -> Result.failure(error)
|
error -> Result.failure(error)
|
||||||
|
|||||||
@@ -1 +1,164 @@
|
|||||||
package io.notevc.commands
|
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<String> {
|
||||||
|
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<FileStatus>()
|
||||||
|
|
||||||
|
// 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<FileStatus>
|
||||||
|
)
|
||||||
|
|
||||||
|
data class FileStatus(
|
||||||
|
val path: String,
|
||||||
|
val type: FileStatusType,
|
||||||
|
val blockChanges: List<BlockChange>? = null,
|
||||||
|
val blockCount: Int? = null
|
||||||
|
)
|
||||||
|
|
||||||
|
enum class FileStatusType {
|
||||||
|
MODIFIED, // File has changes
|
||||||
|
UNTRACKED, // File was not yet commited
|
||||||
|
DELETED // File was removed
|
||||||
|
}
|
||||||
|
|||||||
155
src/main/kotlin/io/notevc/core/BlockParser.kt
Normal file
155
src/main/kotlin/io/notevc/core/BlockParser.kt
Normal file
@@ -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<Block>()
|
||||||
|
val frontMatter = extractFrontMatter(lines)
|
||||||
|
|
||||||
|
var currentBlock: MutableList<String>? = 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 = "<!-- Content -->"
|
||||||
|
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<String>): 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<String, String>()
|
||||||
|
|
||||||
|
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<Block>
|
||||||
|
)
|
||||||
|
|
||||||
|
@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<String, String>,
|
||||||
|
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
|
||||||
|
}
|
||||||
283
src/main/kotlin/io/notevc/core/BlockStore.kt
Normal file
283
src/main/kotlin/io/notevc/core/BlockStore.kt
Normal file
@@ -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<Block>? {
|
||||||
|
val snapshot = getLatestBlockSnapshotBefore(filePath, timestamp)
|
||||||
|
return snapshot?.let { reconstructBlocks(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current blocks for a file
|
||||||
|
fun getCurrentBlocks(filePath: String): List<Block>? {
|
||||||
|
val snapshot = getLatestBlockSnapshot(filePath)
|
||||||
|
return snapshot?.let { reconstructBlocks(it) }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compare blocks between two snapshots
|
||||||
|
fun compareBlocks(oldSnapshot: BlockSnapshot?, newSnapshot: BlockSnapshot): List<BlockChange> {
|
||||||
|
val changes = mutableListOf<BlockChange>()
|
||||||
|
|
||||||
|
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<Block> {
|
||||||
|
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<Pair<BlockSnapshot, Instant>>()
|
||||||
|
|
||||||
|
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<BlockSnapshot>(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<Pair<BlockSnapshot, Instant>>()
|
||||||
|
|
||||||
|
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<BlockSnapshot>(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<BlockSnapshot> {
|
||||||
|
if (!blocksDir.exists()) return emptyList()
|
||||||
|
|
||||||
|
val snapshots = mutableListOf<Pair<BlockSnapshot, Instant>>()
|
||||||
|
|
||||||
|
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<BlockSnapshot>(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<String> {
|
||||||
|
if (!blocksDir.exists()) return emptyList()
|
||||||
|
|
||||||
|
val files = mutableSetOf<String>()
|
||||||
|
|
||||||
|
Files.walk(blocksDir)
|
||||||
|
.filter { it.isRegularFile() && it.fileName.toString().startsWith("blocks-") }
|
||||||
|
.forEach { snapshotFile ->
|
||||||
|
try {
|
||||||
|
val content = Files.readString(snapshotFile)
|
||||||
|
val snapshot = json.decodeFromString<BlockSnapshot>(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<BlockState>,
|
||||||
|
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
|
||||||
|
}
|
||||||
@@ -1 +1,74 @@
|
|||||||
package io.notevc.core
|
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<String> {
|
||||||
|
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
|
||||||
|
)
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import java.time.Instant
|
|||||||
|
|
||||||
class Repository private constructor(private val rootPath: Path) {
|
class Repository private constructor(private val rootPath: Path) {
|
||||||
private val notevcDir = rootPath.resolve(NOTEVC_DIR)
|
private val notevcDir = rootPath.resolve(NOTEVC_DIR)
|
||||||
|
private val objectStore = ObjectStore(notevcDir.resolve("objects"))
|
||||||
val path: Path get() = rootPath
|
val path: Path get() = rootPath
|
||||||
val isInitialized: Boolean get() = notevcDir.exists()
|
val isInitialized: Boolean get() = notevcDir.exists()
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,83 @@
|
|||||||
package io.notevc.utils
|
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<Path> {
|
||||||
|
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<String, FileState> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -1 +1,24 @@
|
|||||||
package io.notevc.utils
|
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}$"))
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user