diff --git a/internal/core/revision/fs_store_test.go b/internal/core/revision/fs_store_test.go index 17cce04f..6b421b98 100644 --- a/internal/core/revision/fs_store_test.go +++ b/internal/core/revision/fs_store_test.go @@ -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 { diff --git a/internal/core/revision/service.go b/internal/core/revision/service.go index c862d669..2bf007d9 100644 --- a/internal/core/revision/service.go +++ b/internal/core/revision/service.go @@ -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" @@ -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 { @@ -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", @@ -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 { @@ -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 { @@ -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 @@ -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 } @@ -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 diff --git a/internal/core/revision/service_test.go b/internal/core/revision/service_test.go index 1f42aef2..50eb222e 100644 --- a/internal/core/revision/service_test.go +++ b/internal/core/revision/service_test.go @@ -8,6 +8,7 @@ import ( "path/filepath" "testing" + "github.com/perber/wiki/internal/core/markdown" sharederrors "github.com/perber/wiki/internal/core/shared/errors" "github.com/perber/wiki/internal/core/tree" ) @@ -629,6 +630,313 @@ func TestRestoreRevisionRehydratesLivePageState(t *testing.T) { } } +func TestRecordContentUpdate_CapturesHistoricalCustomFrontmatter(t *testing.T) { + service, treeService, _ := newRevisionTestService(t) + + pageKind := tree.NodeKindPage + pageIDPtr, err := treeService.CreateNode("tester", nil, "Page", "page", &pageKind) + if err != nil { + t.Fatalf("CreateNode(page) failed: %v", err) + } + pageID := *pageIDPtr + + firstRaw, err := markdown.BuildMarkdownWithExtraFrontmatter(map[string]interface{}{ + "aliases": []string{"one"}, + "customKey": "first", + }, "Body") + if err != nil { + t.Fatalf("BuildMarkdownWithExtraFrontmatter(first) failed: %v", err) + } + if err := treeService.UpdateNode("tester", pageID, "Page", "page", &firstRaw, tree.VersionUnchecked, true); err != nil { + t.Fatalf("UpdateNode(first raw) failed: %v", err) + } + + firstRev, created, err := service.RecordContentUpdate(pageID, "tester", "first") + if err != nil { + t.Fatalf("RecordContentUpdate(first) failed: %v", err) + } + if !created { + t.Fatalf("expected first revision to be created") + } + if got := firstRev.ExtraFrontmatter["customKey"]; got != "first" { + t.Fatalf("expected first revision custom frontmatter, got %#v", firstRev.ExtraFrontmatter) + } + + secondRaw, err := markdown.BuildMarkdownWithExtraFrontmatter(map[string]interface{}{ + "aliases": []string{"two"}, + "customKey": "second", + }, "Body") + if err != nil { + t.Fatalf("BuildMarkdownWithExtraFrontmatter(second) failed: %v", err) + } + if err := treeService.UpdateNode("tester", pageID, "Page", "page", &secondRaw, tree.VersionUnchecked, true); err != nil { + t.Fatalf("UpdateNode(second raw) failed: %v", err) + } + + secondRev, created, err := service.RecordContentUpdate(pageID, "tester", "second") + if err != nil { + t.Fatalf("RecordContentUpdate(second) failed: %v", err) + } + if !created { + t.Fatalf("expected second revision to be created for frontmatter-only change") + } + if secondRev.ID == firstRev.ID { + t.Fatalf("expected distinct revision for changed custom frontmatter") + } + if got := secondRev.ExtraFrontmatter["customKey"]; got != "second" { + t.Fatalf("expected second revision custom frontmatter, got %#v", secondRev.ExtraFrontmatter) + } + aliases, ok := secondRev.ExtraFrontmatter["aliases"].([]interface{}) + if !ok || len(aliases) != 1 || aliases[0] != "two" { + t.Fatalf("expected aliases to be preserved in revision, got %#v", secondRev.ExtraFrontmatter["aliases"]) + } +} + +func TestRestoreRevision_RestoresHistoricalCustomFrontmatterAndKeepsManagedFields(t *testing.T) { + service, treeService, _ := newRevisionTestService(t) + + pageKind := tree.NodeKindPage + pageIDPtr, err := treeService.CreateNode("creator", nil, "Page", "page", &pageKind) + if err != nil { + t.Fatalf("CreateNode(page) failed: %v", err) + } + pageID := *pageIDPtr + + firstRaw, err := markdown.BuildMarkdownWithExtraFrontmatter(map[string]interface{}{ + "aliases": []string{"one"}, + "customKey": "first", + }, "Body") + if err != nil { + t.Fatalf("BuildMarkdownWithExtraFrontmatter(first) failed: %v", err) + } + if err := treeService.UpdateNode("creator", pageID, "Page", "page", &firstRaw, tree.VersionUnchecked, true); err != nil { + t.Fatalf("UpdateNode(first raw) failed: %v", err) + } + firstRev, created, err := service.RecordContentUpdate(pageID, "creator", "first") + if err != nil { + t.Fatalf("RecordContentUpdate(first) failed: %v", err) + } + if !created { + t.Fatalf("expected first revision to be created") + } + + secondRaw, err := markdown.BuildMarkdownWithExtraFrontmatter(map[string]interface{}{ + "aliases": []string{"two"}, + "customKey": "second", + }, "Body changed") + if err != nil { + t.Fatalf("BuildMarkdownWithExtraFrontmatter(second) failed: %v", err) + } + if err := treeService.UpdateNode("editor", pageID, "Changed", "page", &secondRaw, tree.VersionUnchecked, true); err != nil { + t.Fatalf("UpdateNode(second raw) failed: %v", err) + } + if _, _, err := service.RecordContentUpdate(pageID, "editor", "second"); err != nil { + t.Fatalf("RecordContentUpdate(second) failed: %v", err) + } + + beforeRestore, err := treeService.GetPage(pageID) + if err != nil { + t.Fatalf("GetPage(before restore) failed: %v", err) + } + managedID := beforeRestore.ID + managedCreatedAt := beforeRestore.Metadata.CreatedAt + managedCreatorID := beforeRestore.Metadata.CreatorID + beforeUpdatedAt := beforeRestore.Metadata.UpdatedAt + + if err := service.RestoreRevision(pageID, firstRev.ID, "restorer"); err != nil { + t.Fatalf("RestoreRevision failed: %v", err) + } + + page, err := treeService.GetPage(pageID) + if err != nil { + t.Fatalf("GetPage(after restore) failed: %v", err) + } + if page.ID != managedID { + t.Fatalf("expected page ID to remain stable, got %q want %q", page.ID, managedID) + } + if page.Title != "Page" { + t.Fatalf("expected restore to rehydrate revision title, got %q", page.Title) + } + if page.Metadata.CreatedAt != managedCreatedAt { + t.Fatalf("expected created_at to remain stable, got %s want %s", page.Metadata.CreatedAt, managedCreatedAt) + } + if page.Metadata.CreatorID != managedCreatorID { + t.Fatalf("expected creator_id to remain stable, got %q want %q", page.Metadata.CreatorID, managedCreatorID) + } + if page.Metadata.LastAuthorID != "restorer" { + t.Fatalf("expected last author to be restore actor, got %q", page.Metadata.LastAuthorID) + } + if !page.Metadata.UpdatedAt.After(beforeUpdatedAt) { + t.Fatalf("expected updated_at to advance on restore, before=%s after=%s", beforeUpdatedAt, page.Metadata.UpdatedAt) + } + + raw, err := treeService.ReadPageRaw(pageID) + if err != nil { + t.Fatalf("ReadPageRaw failed: %v", err) + } + fm, body, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter(restored raw) failed: %v", err) + } + if !has { + t.Fatalf("expected restored page to have frontmatter") + } + if fm.LeafWikiID != managedID { + t.Fatalf("expected leafwiki_id to remain stable, got %q want %q", fm.LeafWikiID, managedID) + } + if fm.LeafWikiTitle != page.Title { + t.Fatalf("expected leafwiki_title to stay managed by the restored page title, got %q want %q", fm.LeafWikiTitle, page.Title) + } + if fm.LeafWikiCreatorID != managedCreatorID { + t.Fatalf("expected leafwiki_creator_id to remain stable, got %q want %q", fm.LeafWikiCreatorID, managedCreatorID) + } + if fm.LeafWikiLastAuthorID != "restorer" { + t.Fatalf("expected leafwiki_last_author_id to be restore actor, got %q", fm.LeafWikiLastAuthorID) + } + if fm.LeafWikiUpdatedAt == "" { + t.Fatalf("expected leafwiki_updated_at to be set on restore") + } + if got := fm.ExtraFields["customKey"]; got != "first" { + t.Fatalf("expected restored custom frontmatter, got %#v", fm.ExtraFields) + } + aliases, ok := fm.ExtraFields["aliases"].([]interface{}) + if !ok || len(aliases) != 1 || aliases[0] != "one" { + t.Fatalf("expected restored aliases, got %#v", fm.ExtraFields["aliases"]) + } + if body != "Body" { + t.Fatalf("expected restored body from revision, got %q", body) + } +} + +func TestRestoreRevision_LegacyRevisionPreservesCurrentCustomFrontmatter(t *testing.T) { + service, treeService, _ := newRevisionTestService(t) + + pageKind := tree.NodeKindPage + pageIDPtr, err := treeService.CreateNode("creator", nil, "Page", "page", &pageKind) + if err != nil { + t.Fatalf("CreateNode(page) failed: %v", err) + } + pageID := *pageIDPtr + + initialRaw, err := markdown.BuildMarkdownWithExtraFrontmatter(map[string]interface{}{ + "customKey": "current", + }, "Current body") + if err != nil { + t.Fatalf("BuildMarkdownWithExtraFrontmatter(initial) failed: %v", err) + } + if err := treeService.UpdateNode("creator", pageID, "Page", "page", &initialRaw, tree.VersionUnchecked, true); err != nil { + t.Fatalf("UpdateNode(initial raw) failed: %v", err) + } + + page, err := treeService.GetPage(pageID) + if err != nil { + t.Fatalf("GetPage failed: %v", err) + } + + state := service.revisionStateFromPage(page) + contentHash, err := service.store.SaveContentBlob([]byte("Legacy body")) + if err != nil { + t.Fatalf("SaveContentBlob failed: %v", err) + } + legacyRevision, err := service.newRevision(RevisionTypeContentUpdate, state, "legacy-author", "legacy", "") + if err != nil { + t.Fatalf("newRevision failed: %v", err) + } + legacyRevision.ContentHash = contentHash + legacyRevision.ExtraFrontmatter = nil + legacyRevision.ExtraFrontmatterHash = "" + if err := service.store.SaveRevision(legacyRevision); err != nil { + t.Fatalf("SaveRevision failed: %v", err) + } + + if err := service.RestoreRevision(pageID, legacyRevision.ID, "restorer"); err != nil { + t.Fatalf("RestoreRevision failed: %v", err) + } + + raw, err := treeService.ReadPageRaw(pageID) + if err != nil { + t.Fatalf("ReadPageRaw failed: %v", err) + } + fm, body, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter failed: %v", err) + } + if !has { + t.Fatalf("expected frontmatter after restore") + } + if got := fm.ExtraFields["customKey"]; got != "current" { + t.Fatalf("expected legacy restore to preserve current custom frontmatter, got %#v", fm.ExtraFields) + } + if body != "Legacy body" { + t.Fatalf("expected legacy content to be restored, got %q", body) + } +} + +func TestRestoreRevision_LegacyBodyThatLooksLikeFrontmatterStaysBody(t *testing.T) { + service, treeService, _ := newRevisionTestService(t) + + pageKind := tree.NodeKindPage + pageIDPtr, err := treeService.CreateNode("creator", nil, "Page", "page", &pageKind) + if err != nil { + t.Fatalf("CreateNode(page) failed: %v", err) + } + pageID := *pageIDPtr + + initialContent := "Current body" + if err := treeService.UpdateNode("creator", pageID, "Page", "page", &initialContent, tree.VersionUnchecked, false); err != nil { + t.Fatalf("UpdateNode(initial content) failed: %v", err) + } + + page, err := treeService.GetPage(pageID) + if err != nil { + t.Fatalf("GetPage failed: %v", err) + } + + legacyBody := "---\ntitle: not frontmatter\n---\nBody content" + state := service.revisionStateFromPage(page) + contentHash, err := service.store.SaveContentBlob([]byte(legacyBody)) + if err != nil { + t.Fatalf("SaveContentBlob failed: %v", err) + } + legacyRevision, err := service.newRevision(RevisionTypeContentUpdate, state, "legacy-author", "legacy body-only", "") + if err != nil { + t.Fatalf("newRevision failed: %v", err) + } + legacyRevision.ContentHash = contentHash + legacyRevision.ExtraFrontmatter = nil + legacyRevision.ExtraFrontmatterHash = "" + if err := service.store.SaveRevision(legacyRevision); err != nil { + t.Fatalf("SaveRevision failed: %v", err) + } + + if err := service.RestoreRevision(pageID, legacyRevision.ID, "restorer"); err != nil { + t.Fatalf("RestoreRevision failed: %v", err) + } + + restoredPage, err := treeService.GetPage(pageID) + if err != nil { + t.Fatalf("GetPage(after restore) failed: %v", err) + } + if restoredPage.Content != legacyBody { + t.Fatalf("expected YAML-looking content to stay body, got %q", restoredPage.Content) + } + + raw, err := treeService.ReadPageRaw(pageID) + if err != nil { + t.Fatalf("ReadPageRaw failed: %v", err) + } + fm, body, has, err := markdown.ParseFrontmatter(raw) + if err != nil { + t.Fatalf("ParseFrontmatter failed: %v", err) + } + if has && len(fm.ExtraFields) != 0 { + t.Fatalf("expected no custom frontmatter to be introduced, got %#v", fm.ExtraFields) + } + if body != legacyBody { + t.Fatalf("expected raw file body to keep legacy body-only content, got %q", body) + } +} + func TestRecordContentAndStructureRebuildMissingPreviousManifest(t *testing.T) { service, treeService, storageDir := newRevisionTestService(t) pageID := createRevisionTestPage(t, treeService, "Page", "page", "hello") diff --git a/internal/core/revision/types.go b/internal/core/revision/types.go index 647e3e3b..a5567c24 100644 --- a/internal/core/revision/types.go +++ b/internal/core/revision/types.go @@ -19,41 +19,45 @@ type AssetRef struct { } type RevisionState struct { - PageID string - ParentID string - Title string - Slug string - Kind string - Path string - Content string - ContentHash string - Assets []AssetRef - AssetManifestHash string - PageCreatedAt time.Time - PageUpdatedAt time.Time - CreatorID string - LastAuthorID string - CapturedAt time.Time + PageID string + ParentID string + Title string + Slug string + Kind string + Path string + Content string + ContentHash string + ExtraFrontmatter map[string]interface{} + ExtraFrontmatterHash string + Assets []AssetRef + AssetManifestHash string + PageCreatedAt time.Time + PageUpdatedAt time.Time + CreatorID string + LastAuthorID string + CapturedAt time.Time } type Revision struct { - ID string `json:"id"` - PageID string `json:"page_id"` - ParentID string `json:"parent_id,omitempty"` - Type RevisionType `json:"type"` - AuthorID string `json:"author_id"` - CreatedAt time.Time `json:"created_at"` - Title string `json:"title"` - Slug string `json:"slug"` - Kind string `json:"kind"` - Path string `json:"path"` - ContentHash string `json:"content_hash"` - AssetManifestHash string `json:"asset_manifest_hash"` - PageCreatedAt time.Time `json:"page_created_at"` - PageUpdatedAt time.Time `json:"page_updated_at"` - CreatorID string `json:"creator_id"` - LastAuthorID string `json:"last_author_id"` - Summary string `json:"summary,omitempty"` + ID string `json:"id"` + PageID string `json:"page_id"` + ParentID string `json:"parent_id,omitempty"` + Type RevisionType `json:"type"` + AuthorID string `json:"author_id"` + CreatedAt time.Time `json:"created_at"` + Title string `json:"title"` + Slug string `json:"slug"` + Kind string `json:"kind"` + Path string `json:"path"` + ContentHash string `json:"content_hash"` + ExtraFrontmatter map[string]interface{} `json:"extra_frontmatter,omitempty"` + ExtraFrontmatterHash string `json:"extra_frontmatter_hash,omitempty"` + AssetManifestHash string `json:"asset_manifest_hash"` + PageCreatedAt time.Time `json:"page_created_at"` + PageUpdatedAt time.Time `json:"page_updated_at"` + CreatorID string `json:"creator_id"` + LastAuthorID string `json:"last_author_id"` + Summary string `json:"summary,omitempty"` } type assetManifest struct { diff --git a/internal/core/tree/tree_service.go b/internal/core/tree/tree_service.go index 2e1b6a80..fb0227fa 100644 --- a/internal/core/tree/tree_service.go +++ b/internal/core/tree/tree_service.go @@ -928,6 +928,28 @@ func (t *TreeService) GetPage(id string) (*Page, error) { }, nil } +// ReadPageRaw returns the raw markdown of a page including frontmatter. +func (t *TreeService) ReadPageRaw(id string) (string, error) { + t.mu.RLock() + defer t.mu.RUnlock() + + if t.tree == nil { + return "", ErrTreeNotLoaded + } + + page := t.getNodeByIDLocked(id) + if page == nil { + return "", ErrPageNotFound + } + + raw, err := t.store.ReadPageRaw(page) + if err != nil { + return "", fmt.Errorf("could not get page raw content: %w", err) + } + + return raw, nil +} + // ResolvePermalinkTarget resolves a stable page ID to the current route path. func (t *TreeService) ResolvePermalinkTarget(id string) (*PermalinkTarget, error) { t.mu.RLock() diff --git a/ui/leafwiki-ui/package-lock.json b/ui/leafwiki-ui/package-lock.json index 7814d4c0..41bc8929 100644 --- a/ui/leafwiki-ui/package-lock.json +++ b/ui/leafwiki-ui/package-lock.json @@ -19,6 +19,7 @@ "@fontsource/inter": "^5.2.5", "@fsegurai/codemirror-theme-github-light": "^6.2.5", "@lezer/highlight": "^1.2.3", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", @@ -1441,6 +1442,37 @@ "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", "license": "MIT" }, + "node_modules/@radix-ui/react-accordion": { + "version": "1.2.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-accordion/-/react-accordion-1.2.12.tgz", + "integrity": "sha512-T4nygeh9YE9dLRPhAHSeOZi7HBXo+0kYIPJXayZfvWOWA0+n3dESrZbjfDPUABkUNym6Hd+f2IR113To8D2GPA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-collapsible": "1.1.12", + "@radix-ui/react-collection": "1.1.7", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-direction": "1.1.1", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-alert-dialog": { "version": "1.1.15", "resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.15.tgz", @@ -1605,6 +1637,36 @@ } } }, + "node_modules/@radix-ui/react-collapsible": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collapsible/-/react-collapsible-1.1.12.tgz", + "integrity": "sha512-Uu+mSh4agx2ib1uIGPP4/CKNULyajb3p92LsVXmH2EHVMTfZWpll88XJ0j4W0z3f8NK1eYl1+Mf/szHPmcHzyA==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.3", + "@radix-ui/react-compose-refs": "1.1.2", + "@radix-ui/react-context": "1.1.2", + "@radix-ui/react-id": "1.1.1", + "@radix-ui/react-presence": "1.1.5", + "@radix-ui/react-primitive": "2.1.3", + "@radix-ui/react-use-controllable-state": "1.2.2", + "@radix-ui/react-use-layout-effect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-collection": { "version": "1.1.7", "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", diff --git a/ui/leafwiki-ui/package.json b/ui/leafwiki-ui/package.json index eb62c5c7..476856ac 100644 --- a/ui/leafwiki-ui/package.json +++ b/ui/leafwiki-ui/package.json @@ -23,6 +23,7 @@ "@fontsource/inter": "^5.2.5", "@fsegurai/codemirror-theme-github-light": "^6.2.5", "@lezer/highlight": "^1.2.3", + "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", "@radix-ui/react-avatar": "^1.1.10", "@radix-ui/react-checkbox": "^1.3.3", diff --git a/ui/leafwiki-ui/src/components/ui/accordion.tsx b/ui/leafwiki-ui/src/components/ui/accordion.tsx new file mode 100644 index 00000000..b4b55881 --- /dev/null +++ b/ui/leafwiki-ui/src/components/ui/accordion.tsx @@ -0,0 +1,58 @@ +import * as React from 'react' +import * as AccordionPrimitive from '@radix-ui/react-accordion' +import { ChevronDown } from 'lucide-react' + +import { cn } from '@/lib/utils' + +const Accordion = AccordionPrimitive.Root + +const AccordionItem = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +AccordionItem.displayName = 'AccordionItem' + +const AccordionTrigger = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + svg]:rotate-180', + className, + )} + {...props} + > + {children} + + + +)) +AccordionTrigger.displayName = AccordionPrimitive.Trigger.displayName + +const AccordionContent = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + +
{children}
+
+)) +AccordionContent.displayName = AccordionPrimitive.Content.displayName + +export { Accordion, AccordionItem, AccordionTrigger, AccordionContent } diff --git a/ui/leafwiki-ui/src/features/editor/EditorTitleBar.tsx b/ui/leafwiki-ui/src/features/editor/EditorTitleBar.tsx index e58075c1..64df61eb 100644 --- a/ui/leafwiki-ui/src/features/editor/EditorTitleBar.tsx +++ b/ui/leafwiki-ui/src/features/editor/EditorTitleBar.tsx @@ -6,6 +6,7 @@ import { useDialogsStore } from '@/stores/dialogs' import { useTreeStore } from '@/stores/tree' import { Pencil } from 'lucide-react' import { TooltipWrapper } from '../../components/TooltipWrapper' +import { buildEditorFrontmatter } from './frontmatter' import { usePageEditorStore } from './pageEditor' export function EditorTitleBar() { @@ -19,10 +20,26 @@ export function EditorTitleBar() { const openDialog = useDialogsStore((s) => s.openDialog) const getPageByPath = useTreeStore((state) => state.getPageByPath) const dirty = usePageEditorStore((s) => { - const { page, title, slug, content } = s + const { + page, + title, + slug, + content, + tags, + frontmatterFields, + frontmatterUnsupported, + } = s if (!page) return false return ( - page.title !== title || page.slug !== slug || page.content !== content + page.title !== title || + page.slug !== slug || + page.content !== content || + (page.frontmatter ?? '') !== + buildEditorFrontmatter({ + tags, + fields: frontmatterFields, + unsupportedRaw: frontmatterUnsupported, + }) ) }) diff --git a/ui/leafwiki-ui/src/features/editor/PageEditor.tsx b/ui/leafwiki-ui/src/features/editor/PageEditor.tsx index 83576298..6412402c 100644 --- a/ui/leafwiki-ui/src/features/editor/PageEditor.tsx +++ b/ui/leafwiki-ui/src/features/editor/PageEditor.tsx @@ -8,6 +8,8 @@ import { useLocation, useNavigate, useParams } from 'react-router-dom' import { toast } from 'sonner' import { useProgressbarStore } from '../progressbar/progressbar' import MarkdownEditor, { MarkdownEditorRef } from './MarkdownEditor' +import { PageFrontmatterPanel } from './PageFrontmatterPanel' +import { buildEditorFrontmatter } from './frontmatter' import { usePageEditorStore } from './pageEditor' import useNavigationGuard from './useNavigationGuard' import { useToolbarActions } from './useToolbarActions' @@ -23,17 +25,40 @@ export default function PageEditor() { const savePage = usePageEditorStore((s) => s.savePage) const forceOverwrite = usePageEditorStore((s) => s.forceOverwrite) const setContent = usePageEditorStore((s) => s.setContent) + const setTags = usePageEditorStore((s) => s.setTags) + const setFrontmatterFields = usePageEditorStore((s) => s.setFrontmatterFields) const loadPageData = usePageEditorStore((s) => s.loadPageData) const initialPage = usePageEditorStore((s) => s.initialPage) // contains the initial page data when loaded + const tags = usePageEditorStore((s) => s.tags) + const frontmatterFields = usePageEditorStore((s) => s.frontmatterFields) + const frontmatterUnsupported = usePageEditorStore( + (s) => s.frontmatterUnsupported, + ) const notFound = usePageEditorStore((s) => s.notFound) const loading = useProgressbarStore((s) => s.loading) const error = usePageEditorStore((s) => s.error) const openNode = useTreeStore((s) => s.openNode) const dirty = usePageEditorStore((s) => { - const { page, title, slug, content } = s + const { + page, + title, + slug, + content, + tags, + frontmatterFields, + frontmatterUnsupported, + } = s if (!page) return false return ( - page.title !== title || page.slug !== slug || page.content !== content + page.title !== title || + page.slug !== slug || + page.content !== content || + (page.frontmatter ?? '') !== + buildEditorFrontmatter({ + tags, + fields: frontmatterFields, + unsupportedRaw: frontmatterUnsupported, + }) ) }) @@ -126,12 +151,21 @@ export default function PageEditor() { title: currentTitle, slug: currentSlug, content: currentContent, + tags: currentTags, + frontmatterFields: currentFrontmatterFields, + frontmatterUnsupported: currentFrontmatterUnsupported, } = usePageEditorStore.getState() const hasUnsavedChanges = currentPage ? currentPage.title !== currentTitle || currentPage.slug !== currentSlug || - currentPage.content !== currentContent + currentPage.content !== currentContent || + (currentPage.frontmatter ?? '') !== + buildEditorFrontmatter({ + tags: currentTags, + fields: currentFrontmatterFields, + unsupportedRaw: currentFrontmatterUnsupported, + }) : false if (!hasUnsavedChanges) { @@ -179,12 +213,21 @@ export default function PageEditor() { <>
{initialPage && ( - + <> + + + )}
diff --git a/ui/leafwiki-ui/src/features/editor/PageFrontmatterPanel.tsx b/ui/leafwiki-ui/src/features/editor/PageFrontmatterPanel.tsx new file mode 100644 index 00000000..9c59757a --- /dev/null +++ b/ui/leafwiki-ui/src/features/editor/PageFrontmatterPanel.tsx @@ -0,0 +1,352 @@ +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '@/components/ui/accordion' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select' +import { + EditorFrontmatterField, + EditorFrontmatterFieldType, +} from './frontmatter' +import { Plus, Tag, Trash2, X } from 'lucide-react' +import { KeyboardEvent, useMemo, useState } from 'react' + +type PageFrontmatterPanelProps = { + tags: string[] + fields: EditorFrontmatterField[] + hasUnsupportedFields: boolean + onTagsChange: (tags: string[]) => void + onFieldsChange: (fields: EditorFrontmatterField[]) => void +} + +function normalizeTag(tag: string) { + return tag.trim() +} + +function buildEmptyField(): EditorFrontmatterField { + return { + key: '', + type: 'text', + value: '', + } +} + +function valuePlaceholder(type: EditorFrontmatterFieldType) { + switch (type) { + case 'number': + return '42' + case 'boolean': + return 'true' + case 'list': + return 'one item per line' + default: + return 'Value' + } +} + +function getFieldValidation( + field: EditorFrontmatterField, + fields: EditorFrontmatterField[], + index: number, +) { + const key = field.key.trim() + if (!key) return 'Missing key' + + const duplicate = fields.some( + (candidate, candidateIndex) => + candidateIndex !== index && + candidate.key.trim().toLocaleLowerCase() === key.toLocaleLowerCase(), + ) + if (duplicate) return 'Duplicate key' + + if (field.type === 'number' && field.value.trim() !== '') { + return Number.isNaN(Number(field.value.trim())) ? 'Invalid number' : 'OK' + } + + if (field.type === 'list') { + return field.value + .split('\n') + .map((item) => item.trim()) + .filter(Boolean).length > 0 + ? 'OK' + : 'Empty list' + } + + if (field.type === 'text' && field.value.trim() === '') { + return 'Empty value' + } + + return 'OK' +} + +export function PageFrontmatterPanel({ + tags, + fields, + hasUnsupportedFields, + onTagsChange, + onFieldsChange, +}: PageFrontmatterPanelProps) { + const [tagDraft, setTagDraft] = useState('') + + const normalizedTags = useMemo(() => { + const seen = new Set() + return tags.filter((tag) => { + const normalized = normalizeTag(tag) + if (!normalized) return false + const key = normalized.toLocaleLowerCase() + if (seen.has(key)) return false + seen.add(key) + return true + }) + }, [tags]) + + const commitTag = (value: string) => { + const normalized = normalizeTag(value) + if (!normalized) return + const exists = normalizedTags.some( + (tag) => tag.toLocaleLowerCase() === normalized.toLocaleLowerCase(), + ) + if (exists) return + onTagsChange([...normalizedTags, normalized]) + } + + const handleTagKeyDown = (event: KeyboardEvent) => { + if (event.key !== 'Enter' && event.key !== ',') { + return + } + + event.preventDefault() + commitTag(tagDraft) + setTagDraft('') + } + + const updateField = ( + index: number, + patch: Partial, + ) => { + const next = fields.map((field, currentIndex) => + currentIndex === index ? { ...field, ...patch } : field, + ) + onFieldsChange(next) + } + + const removeField = (index: number) => { + onFieldsChange(fields.filter((_, currentIndex) => currentIndex !== index)) + } + + const addField = () => { + onFieldsChange([...fields, buildEmptyField()]) + } + + const summaryParts = [ + normalizedTags.length === 1 ? '1 tag' : `${normalizedTags.length} tags`, + fields.length === 1 ? '1 property' : `${fields.length} properties`, + ] + + return ( +
+ + + +
+
+ + Metadata +
+ + {summaryParts.join(' • ')} + +
+
+ +
+ setTagDraft(event.target.value)} + onKeyDown={handleTagKeyDown} + onBlur={() => { + commitTag(tagDraft) + setTagDraft('') + }} + placeholder="Add tag" + className="page-frontmatter-panel__tag-input" + data-testid="page-frontmatter-tag-input" + /> +
+ {normalizedTags.map((tag) => ( + + {tag} + + + ))} +
+
+ +
+ Properties +
+ + {fields.length > 0 ? ( +
+ {fields.map((field, index) => ( +
+ + updateField(index, { key: event.target.value }) + } + placeholder="Key" + className="page-frontmatter-panel__field-key" + data-testid={`page-frontmatter-field-key-${index}`} + /> + + + {field.type === 'list' ? ( +