Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions internal/core/revision/fs_store_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -173,6 +173,55 @@ func TestFSStoreValidationAndEmptyPaths(t *testing.T) {
}
}

func TestFSStoreGetRevision_BackwardCompatibleWithoutExtraFrontmatterFields(t *testing.T) {
store := NewFSStore(t.TempDir())
createdAt := time.Date(2026, 4, 20, 15, 4, 5, 0, time.UTC)
pageID := "page-1"
revisionID := "rev-legacy"

payload := map[string]interface{}{
"id": revisionID,
"page_id": pageID,
"type": string(RevisionTypeContentUpdate),
"author_id": "tester",
"created_at": createdAt.Format(time.RFC3339),
"title": "Legacy",
"slug": "legacy",
"kind": "page",
"path": "/legacy",
"content_hash": "abc123",
"page_created_at": createdAt.Format(time.RFC3339),
"page_updated_at": createdAt.Format(time.RFC3339),
"creator_id": "creator",
"last_author_id": "editor",
}

revisionPath := store.revisionFilePath(pageID, revisionID, createdAt)
if err := os.MkdirAll(filepath.Dir(revisionPath), 0o755); err != nil {
t.Fatalf("MkdirAll failed: %v", err)
}
if err := writeJSONAtomic(revisionPath, payload); err != nil {
t.Fatalf("writeJSONAtomic failed: %v", err)
}
if err := store.saveRevisionIndex(pageID, revisionIndex{revisionID: filepath.Base(revisionPath)}); err != nil {
t.Fatalf("saveRevisionIndex failed: %v", err)
}

rev, err := store.GetRevision(pageID, revisionID)
if err != nil {
t.Fatalf("GetRevision failed: %v", err)
}
if rev == nil || rev.ID != revisionID {
t.Fatalf("unexpected revision = %#v", rev)
}
if rev.ExtraFrontmatter != nil {
t.Fatalf("expected legacy revision to decode without extra frontmatter, got %#v", rev.ExtraFrontmatter)
}
if rev.ExtraFrontmatterHash != "" {
t.Fatalf("expected empty extra frontmatter hash for legacy revision, got %q", rev.ExtraFrontmatterHash)
}
}

func TestFSStoreListRevisionWrappersAndValidation(t *testing.T) {
store := NewFSStore(t.TempDir())
if got, err := store.ListRevisions("missing"); err != nil || len(got) != 0 {
Expand Down
142 changes: 112 additions & 30 deletions internal/core/revision/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"sync"
"time"

"github.com/perber/wiki/internal/core/markdown"
"github.com/perber/wiki/internal/core/shared"
sharederrors "github.com/perber/wiki/internal/core/shared/errors"
"github.com/perber/wiki/internal/core/tree"
Expand All @@ -28,12 +29,12 @@ type assetManifestEntry struct {
}

type Service struct {
storageDir string
pages *tree.TreeService
store *FSStore
maxRevisions int // 0 = unlimited
log *slog.Logger
assetManifestCache sync.Map // pageID → assetManifestEntry
storageDir string
pages *tree.TreeService
store *FSStore
maxRevisions int // 0 = unlimited
log *slog.Logger
assetManifestCache sync.Map // pageID → assetManifestEntry
}

type ServiceOptions struct {
Expand Down Expand Up @@ -588,8 +589,17 @@ func (s *Service) RestoreRevision(pageID, revisionID, authorID string) error {
)
}

restoredContent := string(content)
if err := s.pages.UpdateNode(authorID, pageID, rev.Title, beforeState.Slug, &restoredContent, tree.VersionUnchecked, false); err != nil {
restoredContent, restoreFromImport, err := buildRestoredRawContent(rev.ExtraFrontmatter, string(content))
if err != nil {
return sharederrors.NewLocalizedError(
"revision_restore_failed",
"Failed to restore page",
"failed to restore page %s",
err,
pageID,
)
}
if err := s.pages.UpdateNode(authorID, pageID, rev.Title, beforeState.Slug, &restoredContent, tree.VersionUnchecked, restoreFromImport); err != nil {
return sharederrors.NewLocalizedError(
"revision_restore_failed",
"Failed to restore page",
Expand All @@ -600,8 +610,13 @@ func (s *Service) RestoreRevision(pageID, revisionID, authorID string) error {
}

if err := s.restoreAssets(pageID, assets); err != nil {
restoreRollbackContent := beforeState.Content
if rollbackErr := s.pages.UpdateNode(authorID, pageID, beforeState.Title, beforeState.Slug, &restoreRollbackContent, tree.VersionUnchecked, false); rollbackErr != nil {
restoreRollbackContent, rollbackFromImport, buildErr := buildRestoredRawContent(beforeState.ExtraFrontmatter, beforeState.Content)
if buildErr != nil {
s.log.Warn("failed to rebuild rollback content", "pageID", pageID, "error", buildErr)
restoreRollbackContent = beforeState.Content
rollbackFromImport = false
}
if rollbackErr := s.pages.UpdateNode(authorID, pageID, beforeState.Title, beforeState.Slug, &restoreRollbackContent, tree.VersionUnchecked, rollbackFromImport); rollbackErr != nil {
s.log.Warn("failed to rollback restored content", "pageID", pageID, "error", rollbackErr)
}
if rollbackErr := s.restoreAssets(pageID, beforeState.Assets); rollbackErr != nil {
Expand All @@ -617,8 +632,13 @@ func (s *Service) RestoreRevision(pageID, revisionID, authorID string) error {
}

if err := s.recordRestoreRevision(pageID, authorID); err != nil {
restoreRollbackContent := beforeState.Content
if rollbackErr := s.pages.UpdateNode(authorID, pageID, beforeState.Title, beforeState.Slug, &restoreRollbackContent, tree.VersionUnchecked, false); rollbackErr != nil {
restoreRollbackContent, rollbackFromImport, buildErr := buildRestoredRawContent(beforeState.ExtraFrontmatter, beforeState.Content)
if buildErr != nil {
s.log.Warn("failed to rebuild rollback content", "pageID", pageID, "error", buildErr)
restoreRollbackContent = beforeState.Content
rollbackFromImport = false
}
if rollbackErr := s.pages.UpdateNode(authorID, pageID, beforeState.Title, beforeState.Slug, &restoreRollbackContent, tree.VersionUnchecked, rollbackFromImport); rollbackErr != nil {
s.log.Warn("failed to rollback restored content", "pageID", pageID, "error", rollbackErr)
}
if rollbackErr := s.restoreAssets(pageID, beforeState.Assets); rollbackErr != nil {
Expand All @@ -643,6 +663,9 @@ func (s *Service) capturePageState(pageID string, withAssets bool) (*RevisionSta
}

state := s.revisionStateFromPage(page)
if err := s.enrichStateWithExtraFrontmatter(page.ID, state); err != nil {
return nil, err
}

if !withAssets {
return state, nil
Expand Down Expand Up @@ -693,8 +716,11 @@ func (s *Service) recordContentUpdateForPage(page *tree.Page, authorID, summary
}

state := s.revisionStateFromPage(page)
if err := s.enrichStateWithExtraFrontmatter(page.ID, state); err != nil {
return nil, false, err
}

if prev != nil && prev.ContentHash == state.ContentHash {
if prev != nil && prev.ContentHash == state.ContentHash && prev.ExtraFrontmatterHash == state.ExtraFrontmatterHash {
return prev, false, nil
}

Expand Down Expand Up @@ -730,26 +756,82 @@ func (s *Service) newRevision(t RevisionType, state *RevisionState, authorID, su
}

return &Revision{
ID: revisionID,
PageID: state.PageID,
ParentID: state.ParentID,
Type: t,
AuthorID: strings.TrimSpace(authorID),
CreatedAt: time.Now().UTC(),
Title: state.Title,
Slug: state.Slug,
Kind: state.Kind,
Path: state.Path,
ContentHash: state.ContentHash,
AssetManifestHash: assetManifestHash,
PageCreatedAt: state.PageCreatedAt.UTC(),
PageUpdatedAt: state.PageUpdatedAt.UTC(),
CreatorID: strings.TrimSpace(state.CreatorID),
LastAuthorID: strings.TrimSpace(state.LastAuthorID),
Summary: summary,
ID: revisionID,
PageID: state.PageID,
ParentID: state.ParentID,
Type: t,
AuthorID: strings.TrimSpace(authorID),
CreatedAt: time.Now().UTC(),
Title: state.Title,
Slug: state.Slug,
Kind: state.Kind,
Path: state.Path,
ContentHash: state.ContentHash,
ExtraFrontmatter: state.ExtraFrontmatter,
ExtraFrontmatterHash: state.ExtraFrontmatterHash,
AssetManifestHash: assetManifestHash,
PageCreatedAt: state.PageCreatedAt.UTC(),
PageUpdatedAt: state.PageUpdatedAt.UTC(),
CreatorID: strings.TrimSpace(state.CreatorID),
LastAuthorID: strings.TrimSpace(state.LastAuthorID),
Summary: summary,
}, nil
}

func (s *Service) enrichStateWithExtraFrontmatter(pageID string, state *RevisionState) error {
if state == nil {
return fmt.Errorf("revision state is required")
}

raw, err := s.pages.ReadPageRaw(pageID)
if err != nil {
return err
}

fm, _, has, err := markdown.ParseFrontmatter(raw)
if err != nil {
return err
}
if !has || len(fm.ExtraFields) == 0 {
state.ExtraFrontmatter = nil
state.ExtraFrontmatterHash = ""
return nil
}

hash, err := hashExtraFrontmatter(fm.ExtraFields)
if err != nil {
return err
}
state.ExtraFrontmatter = fm.ExtraFields
state.ExtraFrontmatterHash = hash
return nil
}

func hashExtraFrontmatter(extra map[string]interface{}) (string, error) {
if len(extra) == 0 {
return "", nil
}

raw, err := json.Marshal(extra)
if err != nil {
return "", fmt.Errorf("marshal extra frontmatter: %w", err)
}
return sha256HexBytes(raw), nil
}

func buildRestoredRawContent(extra map[string]interface{}, body string) (string, bool, error) {
if len(extra) == 0 {
return body, false, nil
}

raw, err := markdown.BuildMarkdownWithExtraFrontmatter(extra, body)
if err != nil {
return "", false, err
}

return raw, true, nil
}

func (s *Service) persistLiveAssets(pageID string, refs []AssetRef) error {
if len(refs) == 0 {
return nil
Expand Down
Loading
Loading