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] 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
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import io.notevc.commands.*
|
||||
fun main(args: Array<String>) {
|
||||
// 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<String>) {
|
||||
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")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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) {
|
||||
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()
|
||||
|
||||
|
||||
@@ -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<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
|
||||
|
||||
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