From d30bb2bef05965b20c214c4c8862fe1c1f1f6d3e Mon Sep 17 00:00:00 2001 From: munenick Date: Mon, 8 Dec 2025 00:23:55 +0900 Subject: [PATCH 01/16] feat: git pull and conflict --- api/openapi/openapi.json | 2 +- api/src/application/dto/git.rs | 31 + api/src/application/ports/git_workspace.rs | 10 +- api/src/application/services/git.rs | 37 +- api/src/application/use_cases/git/helpers.rs | 4 - api/src/application/use_cases/git/mod.rs | 1 + api/src/application/use_cases/git/pull.rs | 31 + api/src/application/use_cases/git/sync_now.rs | 22 +- api/src/bin/export-openapi.rs | 5 + api/src/bin/refmd.rs | 9 + api/src/infrastructure/git/workspace.rs | 639 +++++++++++++++++- api/src/presentation/http/git.rs | 133 +++- app/src/entities/git/api/index.ts | 13 +- .../containers/MarkdownEditor.tsx | 1 - app/src/features/edit-document/ui/Editor.tsx | 34 +- .../edit-document/ui/EditorLayout.tsx | 126 +++- .../file-tree/model/file-tree-context.tsx | 4 + app/src/features/file-tree/model/types.ts | 2 + app/src/features/file-tree/ui/FileNode.tsx | 30 +- app/src/features/git-sync/index.ts | 1 + .../git-sync/lib/git-conflict-store.ts | 24 + .../features/git-sync/ui/git-pull-dialog.tsx | 153 +++++ .../features/git-sync/ui/git-sync-button.tsx | 134 +++- app/src/routes/(app)/document/$id.tsx | 7 +- app/src/shared/api/client/sdk.gen.ts | 20 +- app/src/shared/api/client/types.gen.ts | 32 + app/src/widgets/document/DocumentPage.tsx | 176 ++++- app/src/widgets/settings/GitSyncPage.tsx | 37 +- app/src/widgets/sidebar/FileTree.tsx | 44 +- 29 files changed, 1662 insertions(+), 100 deletions(-) create mode 100644 api/src/application/use_cases/git/pull.rs create mode 100644 app/src/features/git-sync/lib/git-conflict-store.ts create mode 100644 app/src/features/git-sync/ui/git-pull-dialog.tsx diff --git a/api/openapi/openapi.json b/api/openapi/openapi.json index 9b6a0f7f..ccde2efd 100644 --- a/api/openapi/openapi.json +++ b/api/openapi/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} +{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} diff --git a/api/src/application/dto/git.rs b/api/src/application/dto/git.rs index 85ca3a16..9041992e 100644 --- a/api/src/application/dto/git.rs +++ b/api/src/application/dto/git.rs @@ -91,3 +91,34 @@ pub struct GitignoreUpdateDto { pub added: usize, pub patterns: Vec, } + +#[derive(Debug, Clone)] +pub struct GitPullResolutionDto { + pub path: String, + /// one of: ours, theirs, custom_text + pub choice: String, + pub content: Option, +} + +#[derive(Debug, Clone)] +pub struct GitPullRequestDto { + pub resolutions: Vec, +} + +#[derive(Debug, Clone)] +pub struct GitPullConflictItemDto { + pub path: String, + pub is_binary: bool, + pub ours: Option, + pub theirs: Option, + pub base: Option, +} + +#[derive(Debug, Clone)] +pub struct GitPullResultDto { + pub success: bool, + pub message: String, + pub files_changed: u32, + pub commit_hash: Option, + pub conflicts: Option>, +} diff --git a/api/src/application/ports/git_workspace.rs b/api/src/application/ports/git_workspace.rs index 29a3ed49..52a65ff7 100644 --- a/api/src/application/ports/git_workspace.rs +++ b/api/src/application/ports/git_workspace.rs @@ -3,8 +3,8 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, - GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitPullRequestDto, GitPullResultDto, GitRemoteCheckDto, + GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; use crate::application::ports::git_repository::UserGitCfg; @@ -32,6 +32,12 @@ pub trait GitWorkspacePort: Send + Sync { req: &GitSyncRequestDto, cfg: Option<&UserGitCfg>, ) -> anyhow::Result; + async fn pull( + &self, + workspace_id: Uuid, + req: &GitPullRequestDto, + cfg: &UserGitCfg, + ) -> anyhow::Result; async fn check_remote( &self, diff --git a/api/src/application/services/git.rs b/api/src/application/services/git.rs index 3a2b75a1..9267740d 100644 --- a/api/src/application/services/git.rs +++ b/api/src/application/services/git.rs @@ -5,7 +5,8 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ GitChangeItem, GitCommitInfo, GitConfigDto, GitRemoteCheckDto, GitStatusDto, GitSyncRequestDto, - GitSyncResponseDto, GitignoreUpdateDto, UpsertGitConfigInput, + GitSyncResponseDto, GitignoreUpdateDto, GitPullRequestDto, GitPullResultDto, + UpsertGitConfigInput, }; use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::files_repository::FilesRepository; @@ -27,6 +28,7 @@ use crate::application::use_cases::git::gitignore_patterns::{ use crate::application::use_cases::git::ignore_document::IgnoreDocument; use crate::application::use_cases::git::ignore_folder::IgnoreFolder; use crate::application::use_cases::git::init_repo::{DeinitRepo, InitRepo}; +use crate::application::use_cases::git::pull::PullRepository; use crate::application::use_cases::git::sync_now::SyncNow; use crate::application::use_cases::git::upsert_config::UpsertGitConfig; @@ -143,6 +145,16 @@ impl GitService { || msg_lower.contains("status code: 404") { ServiceError::BadRequest("git_repo_not_found") + } else if msg_lower.contains("notfastforward") + || msg_lower.contains("not fast forward") + || msg_lower.contains("non-fast-forward") + || msg_lower.contains("non fast forward") + || msg_lower.contains("cannot push because a reference") + || msg_lower.contains("failed to push some refs") + || msg_lower.contains("updates were rejected") + || msg_lower.contains("rejected") + { + ServiceError::Conflict } else { ServiceError::from(err) } @@ -305,4 +317,27 @@ impl GitService { .await .map_err(ServiceError::from) } + + pub async fn pull_repository( + &self, + workspace_id: Uuid, + req: GitPullRequestDto, + ) -> Result { + let uc = PullRepository { + workspace: self.workspace.as_ref(), + repo: self.repo.as_ref(), + }; + uc.execute(workspace_id, req).await.map_err(|err| { + let msg = err.to_string(); + if msg.contains("pending changes") { + ServiceError::BadRequest("workspace_has_pending_changes") + } else if msg.contains("not initialized") { + ServiceError::BadRequest("repository_not_initialized") + } else if msg.contains("remote not configured") { + ServiceError::BadRequest("remote_not_configured") + } else { + ServiceError::from(err) + } + }) + } } diff --git a/api/src/application/use_cases/git/helpers.rs b/api/src/application/use_cases/git/helpers.rs index 83d39ed4..5ba6ecbd 100644 --- a/api/src/application/use_cases/git/helpers.rs +++ b/api/src/application/use_cases/git/helpers.rs @@ -80,8 +80,4 @@ pub fn needs_force_retry(err: &Error) -> bool { msg.contains("remote repository state diverged") || msg.contains("repository latest commit mismatch") || msg.contains("remote repository already contains commit") - || msg.contains("non-fast-forward") - || msg.contains("non fast forward") - || msg.contains("failed to push some refs") - || msg.contains("rejected") } diff --git a/api/src/application/use_cases/git/mod.rs b/api/src/application/use_cases/git/mod.rs index d70f46d1..01832bc2 100644 --- a/api/src/application/use_cases/git/mod.rs +++ b/api/src/application/use_cases/git/mod.rs @@ -10,5 +10,6 @@ pub mod helpers; pub mod ignore_document; pub mod ignore_folder; pub mod init_repo; +pub mod pull; pub mod sync_now; pub mod upsert_config; diff --git a/api/src/application/use_cases/git/pull.rs b/api/src/application/use_cases/git/pull.rs new file mode 100644 index 00000000..2342d64c --- /dev/null +++ b/api/src/application/use_cases/git/pull.rs @@ -0,0 +1,31 @@ +use anyhow::anyhow; +use uuid::Uuid; + +use crate::application::dto::git::{GitPullRequestDto, GitPullResultDto}; +use crate::application::ports::git_repository::GitRepository; +use crate::application::ports::git_workspace::GitWorkspacePort; + +pub struct PullRepository<'a, R, W> +where + R: GitRepository + ?Sized, + W: GitWorkspacePort + ?Sized, +{ + pub workspace: &'a W, + pub repo: &'a R, +} + +impl<'a, R, W> PullRepository<'a, R, W> +where + R: GitRepository + ?Sized, + W: GitWorkspacePort + ?Sized, +{ + pub async fn execute( + &self, + workspace_id: Uuid, + req: GitPullRequestDto, + ) -> anyhow::Result { + let cfg = self.repo.load_user_git_cfg(workspace_id).await?; + let cfg = cfg.ok_or_else(|| anyhow!("git_not_configured"))?; + self.workspace.pull(workspace_id, &req, &cfg).await + } +} diff --git a/api/src/application/use_cases/git/sync_now.rs b/api/src/application/use_cases/git/sync_now.rs index 1e4cf7b5..21214df5 100644 --- a/api/src/application/use_cases/git/sync_now.rs +++ b/api/src/application/use_cases/git/sync_now.rs @@ -1,10 +1,8 @@ -use tracing::warn; use uuid::Uuid; use crate::application::dto::git::{GitSyncRequestDto, GitSyncResponseDto}; use crate::application::ports::git_repository::GitRepository; use crate::application::ports::git_workspace::GitWorkspacePort; -use crate::application::use_cases::git::helpers::needs_force_retry; pub struct SyncNow<'a, R, W> where @@ -26,25 +24,11 @@ where req: GitSyncRequestDto, ) -> anyhow::Result { let cfg = self.repo.load_user_git_cfg(workspace_id).await?; - let mut attempt_req = req.clone(); - let outcome = match self + let attempt_req = req.clone(); + let outcome = self .workspace .sync(workspace_id, &attempt_req, cfg.as_ref()) - .await - { - Ok(outcome) => outcome, - Err(err) => { - if !attempt_req.force.unwrap_or(false) && needs_force_retry(&err) { - warn!(workspace_id = %workspace_id, "git_sync_retrying_with_force"); - attempt_req.force = Some(true); - self.workspace - .sync(workspace_id, &attempt_req, cfg.as_ref()) - .await? - } else { - return Err(err); - } - } - }; + .await?; if let Some(cfg) = cfg.as_ref() { if !cfg.repository_url.is_empty() { diff --git a/api/src/bin/export-openapi.rs b/api/src/bin/export-openapi.rs index 7eb19e90..9aacf4db 100644 --- a/api/src/bin/export-openapi.rs +++ b/api/src/bin/export-openapi.rs @@ -76,6 +76,7 @@ use utoipa::OpenApi; git::get_working_diff, git::get_commit_diff, git::sync_now, + git::pull_repository, git::init_repository, git::deinit_repository, git::ignore_document, @@ -181,6 +182,10 @@ use utoipa::OpenApi; git::GitStatus, git::GitSyncRequest, git::GitSyncResponse, + git::GitPullRequest, + git::GitPullResponse, + git::GitPullResolution, + git::GitPullConflictItem, git::GitChangeItem, git::GitChangesResponse, git::GitCommitItem, diff --git a/api/src/bin/refmd.rs b/api/src/bin/refmd.rs index 9a05bec2..1b392b59 100644 --- a/api/src/bin/refmd.rs +++ b/api/src/bin/refmd.rs @@ -592,6 +592,15 @@ impl GitWorkspacePort for CliGitWorkspace { bail!("sync not supported in refmd CLI"); } + async fn pull( + &self, + _workspace_id: Uuid, + _req: &api::application::dto::git::GitPullRequestDto, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + bail!("pull not supported in refmd CLI"); + } + async fn check_remote( &self, _workspace_id: Uuid, diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index ee3f493c..c373cbb0 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -19,8 +19,8 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, - GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitPullConflictItemDto, GitPullRequestDto, GitPullResultDto, + GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; use crate::application::ports::git_repository::UserGitCfg; use crate::application::ports::git_storage::{ @@ -31,6 +31,7 @@ use crate::application::ports::storage_port::StorageResolverPort; use crate::application::services::diff::text_diff::compute_text_diff; use crate::application::services::realtime::snapshot::SnapshotService; use crate::infrastructure::db::PgPool; +use tokio::fs as async_fs; pub struct GitWorkspaceService { pool: PgPool, @@ -920,6 +921,66 @@ impl GitWorkspaceService { } } + #[allow(dead_code)] + async fn state_from_commit_meta( + &self, + workspace_id: Uuid, + meta: &CommitMeta, + ) -> anyhow::Result> { + let mut state: HashMap = HashMap::new(); + for path in meta.file_hash_index.keys() { + let Some(bytes) = self + .load_file_snapshot(workspace_id, &meta.commit_id, path) + .await? + else { + continue; + }; + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + state.insert( + path.clone(), + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + Ok(state) + } + + async fn apply_state_to_workspace( + &self, + workspace_id: Uuid, + state: &HashMap, + previous_index: &HashMap, + ) -> anyhow::Result { + let mut changed: u32 = 0; + // write/update files + for (path, snapshot) in state.iter() { + let rel = format!("{}/{}", workspace_id, path.trim_start_matches('/')); + let abs = self.storage.absolute_from_relative(&rel); + if let Some(parent) = abs.parent() { + async_fs::create_dir_all(parent).await?; + } + let bytes = self.snapshot_bytes(snapshot).await?; + self.storage.write_bytes(abs.as_path(), &bytes).await?; + changed += 1; + } + // remove files missing in next state + for path in previous_index.keys() { + if state.contains_key(path) { + continue; + } + let rel = format!("{}/{}", workspace_id, path.trim_start_matches('/')); + let abs = self.storage.absolute_from_relative(&rel); + if async_fs::remove_file(&abs).await.is_ok() { + changed += 1; + } + } + Ok(changed) + } + fn build_diff_result( &self, path: &str, @@ -1894,6 +1955,490 @@ impl GitWorkspacePort for GitWorkspaceService { }) } + async fn pull( + &self, + workspace_id: Uuid, + req: &GitPullRequestDto, + cfg: &UserGitCfg, + ) -> anyhow::Result { + let state = self.load_repository_state(workspace_id).await?; + let Some((initialized, branch_default)) = state else { + anyhow::bail!("repository not initialized"); + }; + if !initialized { + anyhow::bail!("repository not initialized"); + } + if cfg.repository_url.is_empty() { + anyhow::bail!("remote not configured"); + } + + let branch = if cfg.branch_name.is_empty() { + branch_default + } else { + cfg.branch_name.clone() + }; + + // Ensure remote history exists locally (best effort). + let _ = self + .bootstrap_remote_history(workspace_id, cfg, &branch) + .await; + + let latest_meta = self.latest_commit_meta(workspace_id).await?; + let base_index: HashMap = latest_meta + .as_ref() + .map(|m| m.file_hash_index.clone()) + .unwrap_or_default(); + let previous_index = base_index.clone(); + + let temp_dir = TempDirBuilder::new() + .prefix("git-pull-") + .tempdir() + .map_err(|e| anyhow::anyhow!(e))?; + let repo = Repository::init_bare(temp_dir.path())?; + if let Some((_, pack_paths)) = self + .persist_pack_chain( + workspace_id, + latest_meta + .as_ref() + .map(|m| m.commit_id.as_slice()), + ) + .await? + { + apply_pack_files(&repo, &pack_paths)?; + } + + let remote_oid = { + let Some(head) = fetch_remote_head(&repo, cfg, &branch)? else { + return Ok(GitPullResultDto { + success: false, + message: format!("branch '{branch}' not found on remote"), + files_changed: 0, + commit_hash: None, + conflicts: None, + }); + }; + head + }; + + let local_oid = latest_meta + .as_ref() + .and_then(|m| git2::Oid::from_bytes(&m.commit_id).ok()); + // Detect drift between latest commit and current workspace (dirty rows or actual state diff) + let dirty_rows = self.fetch_dirty(workspace_id).await?; + let mut drift_detected = !dirty_rows.is_empty(); + let current_state = self.collect_current_state(workspace_id).await?; + let mut current_index: HashMap = HashMap::new(); + for (path, snapshot) in current_state.iter() { + current_index.insert(path.clone(), snapshot.hash.clone()); + if base_index.get(path) != Some(&snapshot.hash) { + drift_detected = true; + } + } + if !drift_detected { + for path in base_index.keys() { + if !current_index.contains_key(path) { + drift_detected = true; + break; + } + } + } + + // Build remote state directly from fetched pack (git2 tree), independent of DB meta. + fn collect_remote_state( + repo: &Repository, + oid: git2::Oid, + ) -> anyhow::Result> { + let commit = repo.find_commit(oid)?; + let tree = commit.tree()?; + let mut out: HashMap = HashMap::new(); + + fn walk( + repo: &Repository, + tree: &git2::Tree, + prefix: &str, + out: &mut HashMap, + ) -> anyhow::Result<()> { + for entry in tree.iter() { + let name = entry.name().unwrap_or_default(); + let path = if prefix.is_empty() { + name.to_string() + } else { + format!("{prefix}{name}") + }; + match entry.kind() { + Some(git2::ObjectType::Tree) => { + if let Some(sub) = entry.to_object(repo)?.as_tree() { + walk(repo, sub, &(path.clone() + "/"), out)?; + } + } + Some(git2::ObjectType::Blob) => { + let blob = repo.find_blob(entry.id())?; + let bytes = blob.content().to_vec(); + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + out.insert( + path, + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + _ => {} + } + } + Ok(()) + } + + walk(repo, &tree, "", &mut out)?; + Ok(out) + } + + let remote_state = collect_remote_state(&repo, remote_oid)?; + let mut remote_conflicts: Vec = Vec::new(); + let mut remote_changed_paths: HashSet = HashSet::new(); + for (path, snap) in remote_state.iter() { + if base_index.get(path) != Some(&snap.hash) { + remote_changed_paths.insert(path.clone()); + } + } + for path in base_index.keys() { + if !remote_state.contains_key(path) { + remote_changed_paths.insert(path.clone()); + } + } + for path in remote_changed_paths.into_iter() { + let ours_bytes = if let Some(snap) = current_state.get(&path) { + Some(self.snapshot_bytes(snap).await?) + } else { + None + }; + let theirs_bytes = if let Some(snap) = remote_state.get(&path) { + Some(self.snapshot_bytes(snap).await?) + } else { + Some(Vec::new()) + }; + let base_bytes = if let Some(meta) = latest_meta.as_ref() { + self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), &path) + .await? + } else { + None + }; + + let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + + remote_conflicts.push(GitPullConflictItemDto { + path: path.clone(), + is_binary, + ours, + theirs, + base, + }); + } + // If commit IDs differ but no file-level diff was detected (should be rare), + // still treat as remote changes to avoid silent application. + if remote_conflicts.is_empty() { + if let Some(local_oid_val) = local_oid { + if remote_oid != local_oid_val { + remote_conflicts.push(GitPullConflictItemDto { + path: "".to_string(), + is_binary: false, + ours: None, + theirs: None, + base: None, + }); + } + } else { + // No local commit but remote exists. + remote_conflicts.push(GitPullConflictItemDto { + path: "".to_string(), + is_binary: false, + ours: None, + theirs: None, + base: None, + }); + } + } + let remote_changes = !remote_conflicts.is_empty(); + + // If remote has changes and no resolutions are provided, return conflicts and do not apply. + if remote_changes && req.resolutions.is_empty() { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(remote_conflicts), + }); + } + + // If local state drifted and remote has changes, do not apply; surface conflicts only. + if drift_detected && remote_changes { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(remote_conflicts), + }); + } + // If remote contains local, treat as fast-forward. + // If remote contains local and remote has changes, return conflicts (no auto-apply). + if let Some(local_oid_val) = local_oid { + if repo.graph_descendant_of(remote_oid, local_oid_val)? && remote_changes { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(remote_conflicts), + }); + } + } + + // Diverged: merge local into remote (linear, parent = remote) + let Some(local_oid_val) = local_oid else { + anyhow::bail!("no local commit to merge"); + }; + + let (meta, pack_bytes, merged_snapshots, commit_hex) = { + let local_commit = repo.find_commit(local_oid_val)?; + let remote_commit = repo.find_commit(remote_oid)?; + let index = repo.merge_commits(&local_commit, &remote_commit, None)?; + + let conflict_items = collect_conflicts(&repo, &index)?; + if !conflict_items.is_empty() && req.resolutions.is_empty() { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(conflict_items), + }); + } + + // Collect conflict entries for resolution application + let mut conflict_entries: Vec<(String, Option>, Option>, Option>)> = + Vec::new(); + { + let mut conflicts_iter = index.conflicts()?; + while let Some(conflict) = conflicts_iter.next() { + let conflict = conflict?; + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .and_then(|e| std::str::from_utf8(&e.path).ok()) + .ok_or_else(|| anyhow!("missing conflict path"))? + .to_string(); + + let to_bytes = + |entry: Option<&git2::IndexEntry>| -> anyhow::Result>> { + if let Some(e) = entry { + let blob = repo.find_blob(e.id)?; + Ok(Some(blob.content().to_vec())) + } else { + Ok(None) + } + }; + + conflict_entries.push(( + path, + to_bytes(conflict.our.as_ref())?, + to_bytes(conflict.their.as_ref())?, + to_bytes(conflict.ancestor.as_ref())?, + )); + } + } + + let resolution_map: std::collections::HashMap< + String, + &crate::application::dto::git::GitPullResolutionDto, + > = req.resolutions.iter().map(|r| (r.path.clone(), r)).collect(); + + // Build merged state from resolved index (stage 0) plus user resolutions. + let mut merged_snapshots: HashMap = HashMap::new(); + for entry in index.iter() { + if index_entry_stage(&entry) != 0 { + continue; + } + let path = index_entry_path(&entry)?; + let blob = repo.find_blob(entry.id)?; + let bytes = blob.content().to_vec(); + let hash = sha256_hex(&bytes); + let is_text = std::str::from_utf8(&bytes).is_ok(); + merged_snapshots.insert( + path, + FileSnapshot { + hash, + data: FileSnapshotData::Inline(bytes), + is_text, + }, + ); + } + + for (path, ours_bytes, theirs_bytes, _base) in conflict_entries { + let Some(resolution) = resolution_map.get(&path) else { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(collect_conflicts(&repo, &index)?), + }); + }; + let selected_bytes = match resolution.choice.as_str() { + "ours" => ours_bytes.clone(), + "theirs" => theirs_bytes.clone(), + "custom_text" => { + Some(resolution.content.clone().unwrap_or_default().into_bytes()) + } + other => anyhow::bail!("unsupported resolution choice {other}"), + } + .unwrap_or_default(); + let hash = sha256_hex(&selected_bytes); + let is_text = std::str::from_utf8(&selected_bytes).is_ok(); + merged_snapshots.insert( + path.clone(), + FileSnapshot { + hash, + data: FileSnapshotData::Inline(selected_bytes), + is_text, + }, + ); + } + + // Build tree from merged snapshots without async work + let mut entry_map: BTreeMap> = BTreeMap::new(); + for (path, snap) in merged_snapshots.iter() { + let bytes = match &snap.data { + FileSnapshotData::Inline(b) => b.clone(), + FileSnapshotData::StoragePath(_) => { + anyhow::bail!("unexpected storage-backed snapshot during pull merge") + } + }; + entry_map.insert(path.clone(), bytes); + } + let tree_oid = build_tree_from_entries(&repo, &entry_map)?; + let tree = repo.find_tree(tree_oid)?; + let sig = signature_from_parts("RefMD", "refmd@example.com", chrono::Utc::now())?; + let commit_oid = repo.commit( + None, + &sig, + &sig, + "Merge remote changes", + &tree, + &[&remote_commit], + )?; + + let mut file_hash_index: HashMap = HashMap::new(); + for (path, snap) in merged_snapshots.iter() { + file_hash_index.insert(path.clone(), snap.hash.clone()); + } + + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(commit_oid)?; + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let commit_hex = encode_commit_id(commit_oid.as_bytes()); + let meta = CommitMeta { + commit_id: commit_oid.as_bytes().to_vec(), + parent_commit_id: Some(remote_oid.as_bytes().to_vec()), + message: Some("Merge remote changes".to_string()), + author_name: Some("RefMD".to_string()), + author_email: Some("refmd@example.com".to_string()), + committed_at: chrono::Utc::now(), + pack_key: format!("git/packs/{}/{}.pack", workspace_id, commit_hex), + file_hash_index, + }; + + (meta, pack_bytes, merged_snapshots, commit_hex) + }; + + let snapshot_keys = self + .store_commit_snapshots(workspace_id, &meta.commit_id, &merged_snapshots) + .await?; + + if let Err(err) = self + .git_storage + .store_pack(workspace_id, &pack_bytes, &meta) + .await + { + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + return Err(err); + } + + if let Err(err) = self + .git_storage + .set_latest_commit(workspace_id, Some(&meta)) + .await + { + let _ = self + .git_storage + .delete_pack(workspace_id, &meta.commit_id) + .await; + for key in snapshot_keys.iter().rev() { + let _ = self.git_storage.delete_blob(key).await; + } + return Err(err); + } + + let mut tx = self.pool.begin().await?; + sqlx::query( + r#"INSERT INTO git_commits ( + commit_id, + parent_commit_id, + workspace_id, + message, + author_name, + author_email, + committed_at, + pack_key, + file_hash_index + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9)"#, + ) + .bind(meta.commit_id.clone()) + .bind(meta.parent_commit_id.clone()) + .bind(workspace_id) + .bind(meta.message.clone()) + .bind(meta.author_name.clone()) + .bind(meta.author_email.clone()) + .bind(meta.committed_at) + .bind(meta.pack_key.clone()) + .bind(Json(&meta.file_hash_index)) + .execute(&mut *tx) + .await?; + + sqlx::query("UPDATE git_repository_state SET updated_at = now() WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&mut *tx) + .await?; + tx.commit().await?; + + let files_changed = self + .apply_state_to_workspace(workspace_id, &merged_snapshots, &previous_index) + .await?; + + self.clear_dirty(workspace_id).await.ok(); + + Ok(GitPullResultDto { + success: true, + message: "remote changes merged".to_string(), + files_changed, + commit_hash: Some(commit_hex), + conflicts: None, + }) + } + async fn check_remote( &self, workspace_id: Uuid, @@ -2195,6 +2740,96 @@ fn apply_pack_files(repo: &Repository, pack_paths: &[PathBuf]) -> anyhow::Result Ok(()) } +fn collect_conflicts( + repo: &Repository, + index: &git2::Index, +) -> anyhow::Result> { + let mut out = Vec::new(); + let mut conflicts = index.conflicts()?; + while let Some(conflict) = conflicts.next() { + let conflict = conflict?; + let path = conflict + .our + .as_ref() + .or(conflict.their.as_ref()) + .or(conflict.ancestor.as_ref()) + .and_then(|e| std::str::from_utf8(&e.path).ok()) + .unwrap_or("") + .to_string(); + + let to_bytes = |entry: Option<&git2::IndexEntry>| -> anyhow::Result>> { + if let Some(e) = entry { + let blob = repo.find_blob(e.id)?; + Ok(Some(blob.content().to_vec())) + } else { + Ok(None) + } + }; + + let ours_bytes = to_bytes(conflict.our.as_ref())?; + let theirs_bytes = to_bytes(conflict.their.as_ref())?; + let base_bytes = to_bytes(conflict.ancestor.as_ref())?; + + let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + + out.push(GitPullConflictItemDto { + path, + is_binary, + ours, + theirs, + base, + }); + } + Ok(out) +} + +fn index_entry_path(entry: &git2::IndexEntry) -> anyhow::Result { + let raw = &entry.path; + if raw.is_empty() { + anyhow::bail!("empty index entry path"); + } + if let Ok(cstr) = std::ffi::CStr::from_bytes_with_nul(raw) { + Ok(cstr + .to_str() + .unwrap_or_default() + .trim_end_matches('\0') + .to_string()) + } else { + Ok(String::from_utf8_lossy(raw).trim_end_matches('\0').to_string()) + } +} + +fn index_entry_stage(entry: &git2::IndexEntry) -> i32 { + ((entry.flags as u32 >> 12) & 0b11) as i32 +} + +fn as_text_or_binary(path: &str, data: Option<&Vec>) -> (Option, bool) { + let Some(bytes) = data else { return (None, false) }; + match std::str::from_utf8(bytes) { + Ok(s) => (Some(s.to_string()), false), + Err(_) => { + let lower = path.to_ascii_lowercase(); + let looks_text = lower.ends_with(".md") + || lower.ends_with(".markdown") + || lower.ends_with(".txt") + || lower.ends_with(".json") + || lower.ends_with(".yaml") + || lower.ends_with(".yml") + || lower.ends_with(".toml") + || lower.ends_with(".ini"); + if looks_text { + let lossy = String::from_utf8_lossy(bytes).to_string(); + return (Some(lossy), false); + } + (None, true) + } + } +} + + fn extract_host(url: &str) -> Option { let s = url.trim(); let s = s diff --git a/api/src/presentation/http/git.rs b/api/src/presentation/http/git.rs index 386c39ac..ff173aab 100644 --- a/api/src/presentation/http/git.rs +++ b/api/src/presentation/http/git.rs @@ -11,8 +11,8 @@ use crate::presentation::http::auth::{Bearer, validate_bearer}; // Config is no longer needed directly here use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitStatusDto, GitSyncRequestDto, - GitignoreUpdateDto, UpsertGitConfigInput, + GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitPullRequestDto, + GitPullResolutionDto, GitStatusDto, GitSyncRequestDto, GitignoreUpdateDto, UpsertGitConfigInput, }; use crate::application::services::errors::ServiceError; use crate::domain::workspaces::permissions::{PERM_GIT_CONFIGURE, PERM_GIT_INIT, PERM_GIT_SYNC}; @@ -37,6 +37,7 @@ pub fn routes(ctx: AppContext) -> Router { .route("/git/diff/working", get(get_working_diff)) .route("/git/diff/commits/:from/:to", get(get_commit_diff)) .route("/git/sync", post(sync_now)) + .route("/git/pull", post(pull_repository)) .route("/git/init", post(init_repository)) .route("/git/deinit", post(deinit_repository)) .route("/git/ignore/doc/:id", post(ignore_document)) @@ -151,6 +152,48 @@ pub struct UpdateGitConfigRequest { pub auto_sync: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GitPullResolution { + pub path: String, + pub choice: String, + pub content: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema)] +pub struct GitPullRequest { + pub resolutions: Option>, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullConflictItem { + pub path: String, + pub is_binary: bool, + pub ours: Option, + pub theirs: Option, + pub base: Option, +} + +impl From for GitPullConflictItem { + fn from(value: crate::application::dto::git::GitPullConflictItemDto) -> Self { + Self { + path: value.path, + is_binary: value.is_binary, + ours: value.ours, + theirs: value.theirs, + base: value.base, + } + } +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullResponse { + pub success: bool, + pub message: String, + pub files_changed: i32, + pub commit_hash: Option, + pub conflicts: Option>, +} + #[utoipa::path(get, path = "/api/git/config", tag = "Git", responses((status = 200, body = Option)))] pub async fn get_config( State(ctx): State, @@ -542,6 +585,92 @@ pub async fn sync_now( })) } +#[utoipa::path( + post, + path = "/api/git/pull", + tag = "Git", + request_body = GitPullRequest, + responses( + (status = 200, body = GitPullResponse), + (status = 409, body = GitPullResponse, description = "Conflicts detected") + ) +)] +pub async fn pull_repository( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + let service = ctx.git_service(); + let dto = service + .pull_repository( + workspace_id, + GitPullRequestDto { + resolutions: req + .resolutions + .unwrap_or_default() + .into_iter() + .map(|r| GitPullResolutionDto { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + }, + ) + .await + .map_err(|err| { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } + _ => err.to_string(), + }; + let status = map_git_error(err); + let body = GitPullResponse { + success: false, + message, + files_changed: 0, + commit_hash: None, + conflicts: None, + }; + (status, body) + }); + let dto = match dto { + Ok(v) => v, + Err((status, body)) => return Ok((status, Json(body))), + }; + let conflicts = dto + .conflicts + .map(|items| items.into_iter().map(Into::into).collect::>()) + .unwrap_or_default(); + let has_conflicts = !conflicts.is_empty(); + let status = if has_conflicts { StatusCode::CONFLICT } else { StatusCode::OK }; + Ok(( + status, + Json(GitPullResponse { + success: dto.success, + message: dto.message, + files_changed: dto.files_changed as i32, + commit_hash: dto.commit_hash, + conflicts: if has_conflicts { Some(conflicts) } else { None }, + }), + )) +} + #[derive(Debug, Serialize, ToSchema)] pub struct GitChangeItem { pub path: String, diff --git a/app/src/entities/git/api/index.ts b/app/src/entities/git/api/index.ts index a91bf805..13009559 100644 --- a/app/src/entities/git/api/index.ts +++ b/app/src/entities/git/api/index.ts @@ -10,9 +10,17 @@ import { ignoreDocument as apiIgnoreDocument, ignoreFolder as apiIgnoreFolder, initRepository as apiInitRepository, + pullRepository as apiPullRepository, syncNow as apiSyncNow, } from '@/shared/api' -import type { GitChangesResponse, GitHistoryResponse, GitStatus, TextDiffResult } from '@/shared/api' +import type { + GitChangesResponse, + GitHistoryResponse, + GitPullResponse, + GitStatus, + PullRepositoryData, + TextDiffResult, +} from '@/shared/api' export const gitKeys = { all: ['git'] as const, @@ -51,7 +59,10 @@ export { apiCreateOrUpdateConfig as createOrUpdateConfig, apiDeinitRepository as deinitRepository, apiInitRepository as initRepository, + apiPullRepository as pullRepository, apiSyncNow as syncNow, apiIgnoreDocument as ignoreDocument, apiIgnoreFolder as ignoreFolder, } + +export type { GitPullResponse, PullRepositoryData } diff --git a/app/src/features/edit-document/containers/MarkdownEditor.tsx b/app/src/features/edit-document/containers/MarkdownEditor.tsx index 2abb6e54..2887651a 100644 --- a/app/src/features/edit-document/containers/MarkdownEditor.tsx +++ b/app/src/features/edit-document/containers/MarkdownEditor.tsx @@ -24,4 +24,3 @@ export function MarkdownEditor(props: MarkdownEditorProps) { } export default MarkdownEditor - diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 1f21527c..e330fda9 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -56,12 +56,36 @@ export type MarkdownEditorProps = { documentId: string readOnly?: boolean extraRight?: React.ReactNode + conflictView?: { + kind: 'text' | 'binary' + original?: string + modified?: string + onChange?: (value: string) => void + readOnly?: boolean + actions?: { + onKeepMine?: () => void + onTakeTheirs?: () => void + onApplyMerged?: () => void + } + theme?: string + } renderPreview?: (props: PreviewPaneProps) => React.ReactNode } export function MarkdownEditor(props: MarkdownEditorProps) { - const { doc, awareness, initialView: initialViewProp = 'split', userId, userName, documentId, readOnly = false, extraRight, renderPreview } = props + const { + doc, + awareness, + initialView: initialViewProp = 'split', + userId, + userName, + documentId, + readOnly = false, + extraRight, + conflictView, + renderPreview, + } = props const { isDarkMode } = useTheme() const isMobile = useIsMobile() const { setEditor } = useEditorContext() @@ -541,6 +565,14 @@ export function MarkdownEditor(props: MarkdownEditorProps) { showVimStatusBar={isVimMode} uploadStatus={uploadStatus} renderPreview={renderPreview} + conflictView={ + conflictView + ? { + ...conflictView, + theme: monacoTheme, + } + : undefined + } /> diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index 9d1ea196..e9435313 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -1,5 +1,6 @@ import { AlertTriangle, Check, Loader2, SlidersHorizontal, X } from 'lucide-react' import type * as monacoNs from 'monaco-editor' +import { DiffEditor } from '@monaco-editor/react' import { useCallback, useMemo, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' import { overlayPanelClass } from '@/shared/lib/overlay-classes' @@ -38,6 +39,21 @@ export type EditorLayoutProps = { showVimStatusBar: boolean uploadStatus: UploadStatus renderPreview?: (props: PreviewPaneProps) => ReactNode + editorOverlay?: ReactNode + editorBanner?: ReactNode + conflictView?: { + kind: 'text' | 'binary' + original?: string + modified?: string + onChange?: (val: string) => void + readOnly?: boolean + theme?: string + actions?: { + onKeepMine?: () => void + onTakeTheirs?: () => void + onApplyMerged?: () => void + } + } } export function EditorLayout({ @@ -66,6 +82,9 @@ export function EditorLayout({ showVimStatusBar, uploadStatus, renderPreview, + editorOverlay, + editorBanner, + conflictView, }: EditorLayoutProps) { const uploadStatusNode = (() => { if (uploadStatus.state === 'idle') return null @@ -193,11 +212,17 @@ export function EditorLayout({ >
+ 'flex flex-1 min-h-0 flex-col', + !isMobile && 'px-4 pb-6 pt-6 sm:px-6 sm:pb-8 sm:pt-8', + )} + > + {editorBanner ?
{editorBanner}
: null}
+ {editorOverlay ? ( +
+ {editorOverlay} +
+ ) : null}
{uploadStatusNode} {toolbarOpen ? ( @@ -228,18 +253,87 @@ export function EditorLayout({ )}
- { - if (!readOnly) await onEditorDropFiles(files) - }} - isMobile={isMobile} - onMount={onEditorMount} - vimStatusBarRef={vimStatusBarRef} - showVimStatusBar={showVimStatusBar} - /> + {conflictView && conflictView.kind === 'text' ? ( +
+
+ {conflictView.actions?.onKeepMine ? ( + + ) : null} + {conflictView.actions?.onTakeTheirs ? ( + + ) : null} + {conflictView.actions?.onApplyMerged ? ( + + ) : null} +
+
+ { + monacoInstance.editor.defineTheme(conflictView.theme ?? monacoTheme, { + base: monacoTheme.includes('dark') ? 'vs-dark' : 'vs', + inherit: true, + rules: [], + colors: {}, + }) + }} + onMount={(editor, monacoInstance) => { + monacoInstance.editor.setTheme(conflictView.theme ?? monacoTheme) + const modified = editor.getModifiedEditor() + modified.updateOptions({ readOnly: conflictView.readOnly }) + if (conflictView.onChange) { + modified.onDidChangeModelContent(() => { + conflictView.onChange?.(modified.getValue()) + }) + } + }} + language="markdown" + theme={conflictView.theme ?? monacoTheme} + options={{ + readOnly: conflictView.readOnly, + renderSideBySide: false, + minimap: { enabled: false }, + }} + /> +
+
+ ) : conflictView && conflictView.kind === 'binary' ? ( +
+ Binary conflict. Choose a side to continue. +
+ ) : ( + { + if (!readOnly) await onEditorDropFiles(files) + }} + isMobile={isMobile} + onMount={onEditorMount} + vimStatusBarRef={vimStatusBarRef} + showVimStatusBar={showVimStatusBar} + /> + )}
diff --git a/app/src/features/file-tree/model/file-tree-context.tsx b/app/src/features/file-tree/model/file-tree-context.tsx index c0e4d85c..0ed83789 100644 --- a/app/src/features/file-tree/model/file-tree-context.tsx +++ b/app/src/features/file-tree/model/file-tree-context.tsx @@ -54,6 +54,8 @@ type DbDoc = { is_share_mount?: boolean share_mount_id?: string created_by_plugin?: string | null + path?: string | null + desired_path?: string | null } type BuildTreeOptions = { @@ -95,6 +97,8 @@ function buildTree(docs: DbDoc[], options?: BuildTreeOptions): DocumentNode[] { sourceId: d.source_id, title: d.title, type, + path: d.path ?? null, + desiredPath: d.desired_path ?? null, children: [], created_at: d.created_at, updated_at: d.updated_at, diff --git a/app/src/features/file-tree/model/types.ts b/app/src/features/file-tree/model/types.ts index 7e8a7e7e..fc36f05b 100644 --- a/app/src/features/file-tree/model/types.ts +++ b/app/src/features/file-tree/model/types.ts @@ -5,6 +5,8 @@ export type DocumentNode = { type: 'file' | 'folder' // Follows source structure for mounted shares children?: DocumentNode[] + path?: string | null + desiredPath?: string | null created_at?: string updated_at?: string archived?: boolean diff --git a/app/src/features/file-tree/ui/FileNode.tsx b/app/src/features/file-tree/ui/FileNode.tsx index a4716a32..584fd060 100644 --- a/app/src/features/file-tree/ui/FileNode.tsx +++ b/app/src/features/file-tree/ui/FileNode.tsx @@ -1,6 +1,7 @@ "use client" import { useQueries } from '@tanstack/react-query' +import { useRouter } from '@tanstack/react-router' import { FileText, Edit, @@ -11,6 +12,7 @@ import { Globe, Link as LinkIcon, Ban, + AlertTriangle, MessageSquare, Blocks, StickyNote, @@ -27,6 +29,7 @@ import type { LucideIcon } from 'lucide-react' import React, { useState, useCallback, memo, useEffect, useRef } from 'react' import { toast } from 'sonner' +import type { GitPullConflictItem } from '@/shared/api' import useInView from '@/shared/hooks/use-in-view' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -67,6 +70,7 @@ type FileNodeProps = { pluginRules?: FileTreeRule[] onOpenSecondaryViewer?: (id: string, type?: 'document' | 'scrap') => void gitEnabled?: boolean + conflict?: GitPullConflictItem | null } export const FileNode = memo(function FileNode({ @@ -90,6 +94,7 @@ export const FileNode = memo(function FileNode({ pluginRules, onOpenSecondaryViewer, gitEnabled = false, + conflict = null, }: FileNodeProps) { const { sharedDocIds, @@ -100,6 +105,7 @@ export const FileNode = memo(function FileNode({ refreshDocuments, setArchivesExpanded, } = useFileTree() + const router = useRouter() const rowRef = useRef(null) const isRowInView = useInView(rowRef, { rootMargin: '160px' }) const [hasBeenVisible, setHasBeenVisible] = useState(false) @@ -110,6 +116,7 @@ export const FileNode = memo(function FileNode({ const menuGuardRef = useRef<{ block: boolean; timer?: number }>({ block: false }) const isArchived = Boolean(node.archived) const isShareMount = Boolean(node.isShareMount) + const hasConflict = Boolean(conflict) const archiveMutation = useArchiveDocument() const unarchiveMutation = useUnarchiveDocument() @@ -177,6 +184,13 @@ export const FileNode = memo(function FileNode({ } }, [isShareMount, node, onDuplicate]) const handleSelect = useCallback(() => { onSelect(node) }, [node, onSelect]) + const handleOpenConflictResolver = useCallback(() => { + router.navigate({ + to: '/document/$id', + params: { id: node.id }, + search: (prev: Record) => ({ ...prev, conflict: '1' }), + }) + }, [node.id, router]) const handleArchive = useCallback(async () => { if (isShareMount) return try { @@ -444,6 +458,12 @@ export const FileNode = memo(function FileNode({ {sharedDocIds.has(node.id) && } )} + {hasConflict && ( + + + Conflict + + )} @@ -492,6 +512,13 @@ export const FileNode = memo(function FileNode({ > Open in Secondary Viewer + {hasConflict && !isShareMount && ( + guardMenuAction(event, handleOpenConflictResolver)} + > + Open in Conflict Resolver + + )} {!isShareMount && ( <> {gitEnabled && ( @@ -559,7 +586,8 @@ export const FileNode = memo(function FileNode({ prev.isSelected === next.isSelected && prev.isDragging === next.isDragging && prev.isDropTarget === next.isDropTarget && - prev.gitEnabled === next.gitEnabled + prev.gitEnabled === next.gitEnabled && + (prev.conflict?.path || null) === (next.conflict?.path || null) )) export default FileNode diff --git a/app/src/features/git-sync/index.ts b/app/src/features/git-sync/index.ts index 10401645..4569f656 100644 --- a/app/src/features/git-sync/index.ts +++ b/app/src/features/git-sync/index.ts @@ -2,5 +2,6 @@ export { default as GitSyncButton } from './ui/git-sync-button' export { default as GitConfigDialog } from './ui/git-config-dialog' export { default as GitHistoryDialog } from './ui/git-history-dialog' export { default as GitChangesDialog } from './ui/git-changes-dialog' +export { default as GitPullDialog } from './ui/git-pull-dialog' export * from './ui/commit-diff-panel' export * from './ui/working-diff-panel' diff --git a/app/src/features/git-sync/lib/git-conflict-store.ts b/app/src/features/git-sync/lib/git-conflict-store.ts new file mode 100644 index 00000000..458cec5d --- /dev/null +++ b/app/src/features/git-sync/lib/git-conflict-store.ts @@ -0,0 +1,24 @@ +import type { GitPullConflictItem } from '@/shared/api' + +export const GIT_CONFLICT_EVENT = 'refmd:git-conflicts-updated' + +let currentConflicts: GitPullConflictItem[] = [] + +export const readConflicts = (): GitPullConflictItem[] => currentConflicts.slice() + +export const setConflicts = (conflicts: GitPullConflictItem[] | null | undefined) => { + currentConflicts = Array.isArray(conflicts) ? conflicts.slice() : [] + if (typeof window !== 'undefined') { + window.dispatchEvent(new CustomEvent(GIT_CONFLICT_EVENT, { detail: currentConflicts })) + } +} + +export const subscribeConflicts = (handler: (items: GitPullConflictItem[]) => void) => { + if (typeof window === 'undefined') return () => {} + const listener = (event: Event) => { + const detail = (event as CustomEvent).detail || currentConflicts + handler(detail) + } + window.addEventListener(GIT_CONFLICT_EVENT, listener) + return () => window.removeEventListener(GIT_CONFLICT_EVENT, listener) +} diff --git a/app/src/features/git-sync/ui/git-pull-dialog.tsx b/app/src/features/git-sync/ui/git-pull-dialog.tsx new file mode 100644 index 00000000..eedd860f --- /dev/null +++ b/app/src/features/git-sync/ui/git-pull-dialog.tsx @@ -0,0 +1,153 @@ +import React from 'react' +import { AlertTriangle, Loader2 } from 'lucide-react' + +import { Button } from '@/shared/ui/button' +import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' + +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' + +type Props = { + open: boolean + onOpenChange: (open: boolean) => void + conflicts: GitPullConflictItem[] + isLoading: boolean + onResolve: (resolutions: GitPullResolution[]) => void + onRetry?: () => void + emptyWarning?: boolean +} + +export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onResolve, onRetry, emptyWarning }: Props) { + const [choices, setChoices] = React.useState>({}) + + React.useEffect(() => { + if (!open) { + setChoices({}) + } + }, [open]) + + const allResolved = conflicts.length === 0 || conflicts.every((c) => choices[c.path]) + + const handleSubmit = () => { + if (!conflicts.length) { + onOpenChange(false) + return + } + const resolutions: GitPullResolution[] = conflicts + .map((c) => { + const choice = choices[c.path] + if (!choice) return null + return { path: c.path, choice } + }) + .filter(Boolean) as GitPullResolution[] + onResolve(resolutions) + } + + return ( + + + + + + Resolve conflicts + + + Remote is ahead. Choose whether to keep your version or the remote version for each file, then apply. + + + +
+ {isLoading ? ( +
+ + Loading conflicts… +
+ ) : conflicts.length === 0 ? ( +
+
+

+ {emptyWarning + ? 'Server reported conflicts but returned no list.' + : 'No conflicts reported.'} +

+ {emptyWarning ? ( +

+ Retry the pull to attempt fetching the conflict list. If it persists, check Git server logs. +

+ ) : null} +
+ {onRetry ? ( + + ) : null} +
+ ) : ( + conflicts.map((conflict) => { + const choice = choices[conflict.path] + return ( +
+
+
{conflict.path}
+
+ + +
+
+ {!conflict.is_binary ? ( +

+ Text file. Choose the side to keep. +

+ ) : ( +

+ Binary file. Only full-file choice is supported. +

+ )} +
+ ) + }) + )} +
+ + + + + +
+
+ ) +} diff --git a/app/src/features/git-sync/ui/git-sync-button.tsx b/app/src/features/git-sync/ui/git-sync-button.tsx index c92b4ad8..4093eed2 100644 --- a/app/src/features/git-sync/ui/git-sync-button.tsx +++ b/app/src/features/git-sync/ui/git-sync-button.tsx @@ -1,6 +1,6 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' -import { AlertCircle, CheckCircle, Eye, FileX, GitCommit, History, Loader2, Settings } from 'lucide-react' +import { AlertCircle, CheckCircle, Eye, FileX, GitCommit, History, Loader2, RefreshCw, Settings } from 'lucide-react' import { useMemo, useState, useCallback } from 'react' import { toast } from 'sonner' @@ -11,10 +11,14 @@ import { Button } from '@/shared/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' -import { getStatus, getConfig, syncNow, initRepository } from '@/entities/git' +import { getStatus, getConfig, syncNow, initRepository, pullRepository } from '@/entities/git' +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' +import { ApiError } from '@/shared/api' +import { setConflicts as setGlobalConflicts, readConflicts } from '@/features/git-sync/lib/git-conflict-store' import GitChangesDialog from './git-changes-dialog' import GitHistoryDialog from './git-history-dialog' +import GitPullDialog from './git-pull-dialog' type Props = { className?: string; compact?: boolean } @@ -26,6 +30,36 @@ function useGitSyncController() { const isMobile = useIsMobile() const [showChanges, setShowChanges] = useState(false) const [showHistory, setShowHistory] = useState(false) + const [showPullDialog, setShowPullDialog] = useState(false) + const [pullConflicts, setPullConflicts] = useState(() => readConflicts()) + const [emptyConflictWarning, setEmptyConflictWarning] = useState(false) + + const extractConflicts = useCallback((value: unknown): GitPullConflictItem[] => { + const fromArray = (arr: unknown): GitPullConflictItem[] => (Array.isArray(arr) ? (arr as GitPullConflictItem[]) : []) + if (!value) return [] + if (Array.isArray(value)) return fromArray(value) + if (typeof value === 'object') { + const maybeArr = (value as any)?.conflicts + if (Array.isArray(maybeArr)) return fromArray(maybeArr) + } + return [] + }, []) + + const updateConflicts = useCallback( + (list: GitPullConflictItem[] | null | undefined, options?: { allowClear?: boolean }) => { + const safeList = Array.isArray(list) ? list : [] + if (safeList.length === 0 && !options?.allowClear) { + // Keep existing list/cached conflicts when server returns empty unexpectedly + return + } + setPullConflicts(safeList) + setGlobalConflicts(safeList) + if (safeList.length > 0) { + setEmptyConflictWarning(false) + } + }, + [], + ) const { data: status, @@ -51,6 +85,16 @@ function useGitSyncController() { toast.error('Git sync failed: repository URL or branch was not found. Please check the URL/branch and try again.') } else if (lower.includes('git_auth_redirect') || lower.includes('too many redirects') || lower.includes('http (34)')) { toast.error('Git sync failed: remote requires re-authentication. Please re-enter your token/SSH key and ensure SSO is approved.') + } else if (e?.status === 409) { + toast.error('Remote is ahead. Pull and resolve conflicts before syncing.') + const extracted = extractConflicts((e as any)?.body) + const fallback = extracted.length ? extracted : readConflicts() + updateConflicts(fallback) + if (!extracted.length && !fallback.length) { + setEmptyConflictWarning(true) + } + setShowPullDialog(true) + pullMutation.mutate({ resolutions: [] }) } else { toast.error(`Sync failed: ${raw}`) } @@ -67,6 +111,55 @@ function useGitSyncController() { onError: (e: any) => toast.error(`Initialization failed: ${e?.message || e}`), }) + const pullMutation = useMutation({ + mutationFn: (payload?: { resolutions?: GitPullResolution[] }) => + pullRepository({ requestBody: { resolutions: payload?.resolutions ?? [] } }), + onSuccess: (res) => { + const extracted = extractConflicts(res) + const hasConflicts = extracted.length > 0 + if (!res.success) { + updateConflicts(hasConflicts ? extracted : readConflicts()) + setShowPullDialog(true) + if (!hasConflicts) { + setEmptyConflictWarning(true) + toast.error(res.message || 'Conflicts reported but list was empty.') + } else { + setEmptyConflictWarning(false) + toast.error(res.message || 'Pull reported conflicts') + } + return + } + if (hasConflicts) { + updateConflicts(extracted) + setEmptyConflictWarning(false) + setShowPullDialog(true) + return + } + updateConflicts([], { allowClear: true }) + setEmptyConflictWarning(false) + toast.success(res.message || 'Pull completed') + qc.invalidateQueries({ queryKey: ['git-status'] }) + }, + onError: (e: any) => { + const bodyConflicts = extractConflicts((e as any)?.body) + if (e instanceof ApiError && e.status === 409 && bodyConflicts.length > 0) { + updateConflicts(bodyConflicts) + setShowPullDialog(true) + return + } + if (e instanceof ApiError && e.status === 409) { + // Fallback: open dialog and retry fetch inside to display conflicts + updateConflicts(readConflicts()) + setEmptyConflictWarning(true) + toast.error('Conflicts reported but server returned no list.') + setShowPullDialog(true) + return + } + const msg = e?.body?.message || e?.message || `${e}` + toast.error(`Pull failed: ${msg}`) + }, + }) + const syncPending = syncMutation.isPending || initMutation.isPending const hasChanges = ((status?.uncommitted_changes || 0) + (status?.untracked_files || 0)) > 0 const isConfigured = Boolean(config) && Boolean(status?.repository_initialized) @@ -137,6 +230,12 @@ function useGitSyncController() { setShowHistory, isConfigured, showButton, + showPullDialog, + setShowPullDialog, + pullMutation, + pullConflicts, + updateConflicts, + emptyConflictWarning, } } @@ -158,6 +257,12 @@ export default function GitSyncButton({ className, compact = false }: Props) { setShowHistory, isConfigured, showButton, + showPullDialog, + setShowPullDialog, + pullMutation, + pullConflicts, + updateConflicts, + emptyConflictWarning, } = controller const [menuOpen, setMenuOpen] = useState(false) @@ -215,6 +320,22 @@ export default function GitSyncButton({ className, compact = false }: Props) { )} Sync Now + { + updateConflicts(readConflicts(), { allowClear: true }) + setShowPullDialog(true) + pullMutation.mutate({ resolutions: [] }) + setMenuOpen(false) + }} + disabled={!isConfigured || pullMutation.isPending} + > + {pullMutation.isPending ? ( + + ) : ( + + )} + Pull (resolve conflicts) + { openChanges() @@ -251,6 +372,15 @@ export default function GitSyncButton({ className, compact = false }: Props) { + pullMutation.mutate({ resolutions })} + onRetry={() => pullMutation.mutate({ resolutions: [] })} + /> ) } diff --git a/app/src/routes/(app)/document/$id.tsx b/app/src/routes/(app)/document/$id.tsx index 32ce785f..784a7c96 100644 --- a/app/src/routes/(app)/document/$id.tsx +++ b/app/src/routes/(app)/document/$id.tsx @@ -12,6 +12,7 @@ import SecondaryViewer from '@/widgets/secondary-viewer/SecondaryViewer' export type DocumentRouteSearch = { token?: string + conflict?: string [key: string]: string | string[] | undefined } @@ -48,9 +49,9 @@ export const Route = createFileRoute('/(app)/document/$id')({ const meta = await fetchDocumentMeta(params.id, token) const title = typeof meta?.title === 'string' ? meta.title.trim() : '' const createdByPlugin = meta?.created_by_plugin ?? null - return { title, token, createdByPlugin } satisfies LoaderData + return { title, token, createdByPlugin, path: meta?.path ?? null, desired_path: meta?.desired_path ?? null } satisfies LoaderData } catch { - return { title: '', token, createdByPlugin: undefined } satisfies LoaderData + return { title: '', token, createdByPlugin: undefined, path: null, desired_path: null } satisfies LoaderData } }, head: ({ loaderData, params }) => { @@ -97,11 +98,13 @@ function DocumentRouteComponent() { const loaderData = Route.useLoaderData() as LoaderData | undefined const search = Route.useSearch() as DocumentRouteSearch const shareToken = loaderData?.token ?? (typeof search.token === 'string' && search.token.trim().length > 0 ? search.token.trim() : undefined) + const conflictMode = Object.prototype.hasOwnProperty.call(search, 'conflict') return ( } /> ) diff --git a/app/src/shared/api/client/sdk.gen.ts b/app/src/shared/api/client/sdk.gen.ts index 2f160447..2584f6f6 100644 --- a/app/src/shared/api/client/sdk.gen.ts +++ b/app/src/shared/api/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; +import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; /** * @param data The data for the request. @@ -775,6 +775,24 @@ export const initRepository = (): CancelablePromise => { }); }; +/** + * @param data The data for the request. + * @param data.requestBody + * @returns GitPullResponse + * @throws ApiError + */ +export const pullRepository = (data: PullRepositoryData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull', + body: data.requestBody, + mediaType: 'application/json', + errors: { + 409: 'Conflicts detected' + } + }); +}; + /** * @returns GitStatus * @throws ApiError diff --git a/app/src/shared/api/client/types.gen.ts b/app/src/shared/api/client/types.gen.ts index b860e40f..f21da29b 100644 --- a/app/src/shared/api/client/types.gen.ts +++ b/app/src/shared/api/client/types.gen.ts @@ -245,6 +245,32 @@ export type GitHistoryResponse = { commits: Array; }; +export type GitPullConflictItem = { + base?: (string) | null; + is_binary: boolean; + ours?: (string) | null; + path: string; + theirs?: (string) | null; +}; + +export type GitPullRequest = { + resolutions?: Array | null; +}; + +export type GitPullResolution = { + choice: string; + content?: (string) | null; + path: string; +}; + +export type GitPullResponse = { + commit_hash?: (string) | null; + conflicts?: Array | null; + files_changed: number; + message: string; + success: boolean; +}; + export type GitRemoteCheckResponse = { message: string; ok: boolean; @@ -1103,6 +1129,12 @@ export type IgnoreFolderResponse = (unknown); export type InitRepositoryResponse = (unknown); +export type PullRepositoryData = { + requestBody: GitPullRequest; +}; + +export type PullRepositoryResponse = (GitPullResponse); + export type GetStatusResponse = (GitStatus); export type SyncNowData = { diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index aa7b494d..d5e3fe86 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -1,15 +1,16 @@ -import { useQueryClient } from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { BookmarkPlus, Download, History } from 'lucide-react' import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' import { toast } from 'sonner' -import { ApiError } from '@/shared/api' +import { ApiError, type GitPullConflictItem, type GitPullResolution } from '@/shared/api' import { useRealtime } from '@/shared/contexts/realtime-context' import type { DocumentHeaderAction } from '@/shared/types/document' import { downloadDocumentFile, type DocumentDownloadFormat } from '@/entities/document' import { createShareMount, shareMountsQuery } from '@/entities/share' +import { pullRepository } from '@/entities/git' import { useAuthContext } from '@/features/auth' import { BacklinksPanel } from '@/features/document-backlinks' @@ -21,15 +22,17 @@ import { import { SnapshotHistoryDialog } from '@/features/document-snapshots' import { EditorOverlay, MarkdownEditor, useCollaborativeDocument, useViewContext } from '@/features/edit-document' import { usePluginDocumentRedirect } from '@/features/plugins' +import { setConflicts as setGlobalConflicts } from '@/features/git-sync/lib/git-conflict-store' import { useSecondaryViewer } from '@/features/secondary-viewer' - type SecondaryViewerType = ReturnType['secondaryDocumentType'] export type DocumentLoaderData = { title: string token?: string createdByPlugin?: string | null + path?: string | null + desired_path?: string | null } export type SecondaryViewerRendererProps = { @@ -45,9 +48,31 @@ export type DocumentPageProps = { loaderData?: DocumentLoaderData shareToken?: string secondaryViewerRenderer?: (props: SecondaryViewerRendererProps) => ReactNode + conflictMode?: boolean } -export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRenderer }: DocumentPageProps) { +const normalizeConflictPath = (path?: string | null) => (path || '').replace(/^[./]+/, '').trim().toLowerCase() + +const matchConflictToDoc = ( + conflicts: GitPullConflictItem[], + docPaths: Array, +): GitPullConflictItem | null => { + const targets = docPaths + .map((p) => normalizeConflictPath(p)) + .filter((p) => p.length > 0) + if (conflicts.length === 0) return null + for (const conflict of conflicts) { + const candidate = normalizeConflictPath(conflict.path) + if (!candidate) continue + if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`))) { + return conflict + } + } + if (conflicts.length === 1) return conflicts[0] + return null +} + +export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRenderer, conflictMode = false }: DocumentPageProps) { const [isClient, setIsClient] = useState(typeof window !== 'undefined') useEffect(() => { @@ -64,6 +89,7 @@ export function DocumentPage({ id, loaderData, shareToken, secondaryViewerRender loaderData={loaderData} shareToken={shareToken} secondaryViewerRenderer={secondaryViewerRenderer} + conflictMode={conflictMode} /> ) } @@ -83,6 +109,7 @@ function DocumentClient({ loaderData, shareToken, secondaryViewerRenderer, + conflictMode = false, }: DocumentPageProps) { const navigate = useNavigate() const qc = useQueryClient() @@ -92,6 +119,8 @@ function DocumentClient({ const [showDownloadDialog, setShowDownloadDialog] = useState(false) const [downloadPending, setDownloadPending] = useState(false) const [savingShare, setSavingShare] = useState(false) + const [activeConflict, setActiveConflict] = useState(null) + const [modifiedText, setModifiedText] = useState('') const { secondaryDocumentId, secondaryDocumentType, showSecondaryViewer, closeSecondaryViewer, openSecondaryViewer } = useSecondaryViewer() const { showBacklinks, setShowBacklinks } = useViewContext() const { status, doc, awareness, isReadOnly, error: realtimeError } = useCollaborativeDocument(id, shareToken) @@ -126,6 +155,33 @@ function DocumentClient({ const loaderTitle = loaderData?.title const resolvedTitle = (realtimeTitle && realtimeTitle.trim()) || loaderTitle + const setConflictsForDoc = useCallback( + (list: GitPullConflictItem[]) => { + const safeList = Array.isArray(list) ? list : [] + setGlobalConflicts(safeList) + const matched = matchConflictToDoc(safeList, [loaderData?.path, loaderData?.desired_path]) + setActiveConflict(matched) + setModifiedText(matched?.theirs ?? matched?.ours ?? '') + }, + [loaderData?.desired_path, loaderData?.path], + ) + + useEffect(() => { + if (!conflictMode) return + const fetchConflicts = async () => { + try { + const res = await pullRepository({ requestBody: { resolutions: [] } }) + setConflictsForDoc(res.conflicts ?? []) + } catch (error) { + if (error instanceof ApiError && error.status === 409) { + const list = ((error.body as any)?.conflicts ?? []) as GitPullConflictItem[] + setConflictsForDoc(list) + } + } + } + void fetchConflicts() + }, [conflictMode, setConflictsForDoc]) + const openDownloadDialog = useCallback(() => { if (!hasDoc) return setShowDownloadDialog(true) @@ -174,6 +230,65 @@ function DocumentClient({ } }, [qc, savingShare, shareToken]) + const handleConflictResolved = useCallback(() => { + navigate({ + to: '/(app)/document/$id', + params: { id }, + search: (prev: Record) => { + const next = { ...prev } + if (next && Object.prototype.hasOwnProperty.call(next, 'conflict')) { + delete (next as any).conflict + } + return next + }, + replace: true, + }) + qc.invalidateQueries({ queryKey: ['git-status'] }) + }, [id, navigate, qc]) + + const pullMutation = useMutation({ + mutationFn: async (resolution: GitPullResolution) => + pullRepository({ requestBody: { resolutions: [resolution] } }), + onSuccess: (res) => { + const remaining = res.conflicts ?? [] + setConflictsForDoc(remaining) + const stillPending = matchConflictToDoc(remaining, [loaderData?.path, loaderData?.desired_path]) + if (stillPending) { + toast.success(res.message || 'Resolution applied. Another conflict remains for this document.') + } else { + toast.success(res.message || 'Conflict resolved') + handleConflictResolved() + } + }, + onError: (err) => { + if (err instanceof ApiError && err.status === 409) { + const next = ((err.body as any)?.conflicts ?? []) as GitPullConflictItem[] + setConflictsForDoc(next) + toast.error((err.body as any)?.message || 'Conflicts remain. Please resolve and try again.') + return + } + const msg = (err as any)?.body?.message || (err as any)?.message || 'Failed to apply resolution' + toast.error(msg) + }, + }) + + const handleApplyResolution = useCallback( + (choice: GitPullResolution['choice']) => { + if (!activeConflict) return + if (choice === 'custom_text' && !modifiedText.trim()) { + toast.error('Add your merged content before applying.') + return + } + const resolution: GitPullResolution = { + path: activeConflict.path, + choice, + content: choice === 'custom_text' ? modifiedText : undefined, + } + pullMutation.mutate(resolution) + }, + [activeConflict, modifiedText, pullMutation], + ) + useEffect(() => { const ensureAction = ( list: DocumentHeaderAction[], @@ -248,6 +363,8 @@ function DocumentClient({ : status === 'connecting' ? 'Connecting…' : 'Loading…' + const showEditor = Boolean(doc && awareness && !realtimeError) + const showOverlay = shouldShowOverlay useEffect(() => { if (typeof document === 'undefined') return @@ -305,20 +422,61 @@ function DocumentClient({ const renderSecondaryViewer = secondaryViewerRenderer + const oursText = activeConflict?.ours ?? '' + const theirsText = activeConflict?.theirs ?? '' + const isBinaryConflict = activeConflict?.is_binary ?? false + + const showConflictUI = Boolean(conflictMode) + + const conflictView = showConflictUI + ? activeConflict + ? { + kind: isBinaryConflict ? 'binary' as const : 'text' as const, + original: oursText, + modified: modifiedText, + onChange: setModifiedText, + readOnly: pullMutation.isPending, + actions: { + onKeepMine: () => { + setModifiedText(oursText) + handleApplyResolution('ours') + }, + onTakeTheirs: () => { + setModifiedText(theirsText) + handleApplyResolution('theirs') + }, + onApplyMerged: !isBinaryConflict + ? () => { + handleApplyResolution('custom_text') + } + : undefined, + }, + } + : { + kind: 'binary' as const, + original: '', + modified: '', + onChange: () => {}, + readOnly: true, + actions: undefined, + } + : undefined + return (
- {shouldShowOverlay && } - {doc && awareness && !realtimeError && ( + {showOverlay && } + {showEditor ? ( setShowBacklinks(false)} /> @@ -333,7 +491,7 @@ function DocumentClient({ ) : undefined } /> - )} + ) : null} ('token') const [token, setToken] = React.useState('') const [privateKey, setPrivateKey] = React.useState('') - const [autoSync, setAutoSync] = React.useState(true) const [lastCheck, setLastCheck] = React.useState(null) const lastSecretRef = React.useRef<{ token?: string; private_key?: string }>({}) + const autoSync = false React.useEffect(() => { if (config) { @@ -49,7 +47,6 @@ export default function GitSyncPage() { setAuthType(config.auth_type === 'ssh' ? 'ssh' : 'token') setToken('') setPrivateKey('') - setAutoSync(config.auto_sync ?? true) setLastCheck((config as any).remote_check ?? null) } }, [config]) @@ -105,17 +102,6 @@ export default function GitSyncPage() { onError: (e: any) => toast.error(`Initialization failed: ${e?.message || e}`), }) - const syncMutation = useMutation({ - mutationFn: () => syncNow({ requestBody: { message: undefined } }), - onSuccess: (data: any) => { - const changed = data?.files_changed ?? 0 - const msg = data?.message || 'Sync completed' - toast.success(`${msg}: ${changed} files changed`) - qc.invalidateQueries({ queryKey: ['git-status'] }) - }, - onError: (e: any) => toast.error(`Sync failed: ${e?.message || e}`), - }) - const deinitMutation = useMutation({ mutationFn: () => deinitRepository(), onSuccess: () => { @@ -267,13 +253,6 @@ export default function GitSyncPage() {

Leave the secret blank to keep the existing one.

-
-
-

Auto sync

-

Push/pull periodically in background.

-
- -
{authType === 'token' ? ( @@ -306,6 +285,10 @@ export default function GitSyncPage() { +

+ Auto sync is off. Use Pull to fetch remote changes and Sync to push manually. +

+
- {repositoryInitialized ? ( - - ) : null}
diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index 02a2ad07..65b2876f 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -4,7 +4,7 @@ import { Archive, Building2, Check, ChevronDown, ChevronRight, FileText, Github, import React, { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' -import type { WorkspaceMembershipResponse } from '@/shared/api' +import type { GitPullConflictItem, WorkspaceMembershipResponse } from '@/shared/api' import { useShortcut } from '@/shared/hooks/use-shortcut' import { overlayMenuClass, overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -31,6 +31,7 @@ import { useFileTreeDrag } from '@/features/file-tree/lib/useFileTreeDrag' import FileNode from '@/features/file-tree/ui/FileNode' import FolderNode from '@/features/file-tree/ui/FolderNode' import { GitSyncButton } from '@/features/git-sync' +import { GIT_CONFLICT_EVENT, readConflicts } from '@/features/git-sync/lib/git-conflict-store' import { useSecondaryViewer } from '@/features/secondary-viewer' import { ShareDialog } from '@/features/sharing' import { @@ -265,6 +266,7 @@ function FileTreeInner() { const [temporaryEntries, setTemporaryEntries] = useState([]) const [shareFolderId, setShareFolderId] = useState(null) const [workspaceDownloadPending, setWorkspaceDownloadPending] = useState(false) + const [gitConflicts, setGitConflicts] = useState(() => readConflicts()) const openTemporaryDocument = useCallback(() => { if (typeof window === 'undefined') return const entry = createTemporaryDocumentEntry() @@ -340,6 +342,16 @@ function FileTreeInner() { } }, []) + useEffect(() => { + const handler = () => setGitConflicts(readConflicts()) + window.addEventListener(GIT_CONFLICT_EVENT, handler) + window.addEventListener('storage', handler) + return () => { + window.removeEventListener(GIT_CONFLICT_EVENT, handler) + window.removeEventListener('storage', handler) + } + }, []) + const { pluginMenu, pluginRules: fileTreeRules, @@ -428,6 +440,28 @@ function FileTreeInner() { await openNode(node) }, [openNode]) + const normalizeConflictPath = useCallback((path?: string | null) => { + if (!path) return '' + return path.replace(/^[./]+/, '').trim().toLowerCase() + }, []) + + const conflictForNode = useCallback( + (node: DocumentNode): GitPullConflictItem | null => { + if (node.type !== 'file') return null + const targets = [normalizeConflictPath(node.path), normalizeConflictPath(node.desiredPath)].filter(Boolean) + if (!targets.length) return null + for (const conflict of gitConflicts) { + const candidate = normalizeConflictPath(conflict.path) + if (!candidate) continue + if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`))) { + return conflict + } + } + return null + }, + [gitConflicts, normalizeConflictPath], + ) + // Sync selection from current URL (when user navigates elsewhere) useEffect(() => { const m = pathname.match(/[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}/) @@ -715,6 +749,8 @@ function FileTreeInner() { ) } + const conflict = conflictForNode(node) + return ( ) - }, [createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) const renderNestedNode = useCallback((node: DocumentNode, parentId?: string, depth = 1): React.ReactNode => { const isExpanded = expandedFolders.has(node.id) @@ -797,9 +834,10 @@ function FileTreeInner() { pluginRules={fileTreeRules} onOpenSecondaryViewer={openSecondaryViewer} gitEnabled + conflict={conflictForNode(node)} /> ) - }, [createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) + }, [conflictForNode, createDocument, createFolder, deleteDocument, drag, duplicateDocument, expandedFolders, fileTreeRules, handleDrop, onSelect, openSecondaryViewer, renameDocument, selectedDocId, setShareFolderId, toggleFolder]) return (
From 13cdd44ea5ebf536c8b92cc3521e9a9b144e4c20 Mon Sep 17 00:00:00 2001 From: munenick Date: Mon, 8 Dec 2025 02:23:08 +0900 Subject: [PATCH 02/16] update: diff css --- .../edit-document/lib/monaco/theme.ts | 3 + .../edit-document/ui/EditorLayout.tsx | 62 ++++++++----------- app/src/styles.css | 7 +++ 3 files changed, 35 insertions(+), 37 deletions(-) diff --git a/app/src/features/edit-document/lib/monaco/theme.ts b/app/src/features/edit-document/lib/monaco/theme.ts index 664dd22f..3ef832d2 100644 --- a/app/src/features/edit-document/lib/monaco/theme.ts +++ b/app/src/features/edit-document/lib/monaco/theme.ts @@ -120,6 +120,9 @@ const buildTheme = (name: string, palette: Palette, isDark: boolean): ThemeDefin 'editorIndentGuide.activeBackground': hexWithAlpha(palette.primary, isDark ? 0.3 : 0.24), 'editor.lineHighlightBackground': hexWithAlpha(palette.primary, isDark ? 0.08 : 0.06), 'editor.selectionHighlightBorder': hexWithAlpha(palette.foreground, 0.16), + // Diff: align with Git History/Changes/Snapshot viewer colors + 'diffEditor.insertedTextBackground': isDark ? hexWithAlpha('#052e16', 0.4) : '#f0fdf4', + 'diffEditor.removedTextBackground': isDark ? hexWithAlpha('#450a0a', 0.4) : '#fef2f2', }, }, } diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index e9435313..7553a8fc 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -9,6 +9,7 @@ import type { ViewMode } from '@/shared/types/view-mode' import { Button } from '@/shared/ui/button' import type { UploadStatus } from '@/features/edit-document/hooks/useEditorUploads' +import { ensureRefmdThemes } from '@/features/edit-document/lib/monaco/theme' import EditorPane from './EditorPane' import PreviewPane, { type PreviewPaneProps } from './PreviewPane' @@ -254,52 +255,31 @@ export function EditorLayout({
{conflictView && conflictView.kind === 'text' ? ( -
-
- {conflictView.actions?.onKeepMine ? ( - - ) : null} - {conflictView.actions?.onTakeTheirs ? ( - - ) : null} - {conflictView.actions?.onApplyMerged ? ( - - ) : null} -
+
{ - monacoInstance.editor.defineTheme(conflictView.theme ?? monacoTheme, { - base: monacoTheme.includes('dark') ? 'vs-dark' : 'vs', - inherit: true, - rules: [], - colors: {}, - }) + ensureRefmdThemes(monacoInstance) }} onMount={(editor, monacoInstance) => { monacoInstance.editor.setTheme(conflictView.theme ?? monacoTheme) const modified = editor.getModifiedEditor() - modified.updateOptions({ readOnly: conflictView.readOnly }) + const original = editor.getOriginalEditor() + // Align gutters; show line numbers only on original + original.updateOptions({ + glyphMargin: false, + lineDecorationsWidth: 24, + lineNumbersMinChars: 1, // Monaco enforces >=1 + lineNumbers: 'on' as const, + }) + modified.updateOptions({ + glyphMargin: false, + lineDecorationsWidth: 24, + lineNumbersMinChars: 1, // Monaco enforces >=1 + lineNumbers: 'off' as const, + }) if (conflictView.onChange) { modified.onDidChangeModelContent(() => { conflictView.onChange?.(modified.getValue()) @@ -311,7 +291,15 @@ export function EditorLayout({ options={{ readOnly: conflictView.readOnly, renderSideBySide: false, + renderMarginRevertIcon: false, + renderOverviewRuler: false, + renderIndicators: false, minimap: { enabled: false }, + automaticLayout: true, + wordWrap: 'on', + scrollBeyondLastLine: true, + fontSize: isMobile ? 17 : 14, + lineHeight: isMobile ? 26 : 22, }} />
diff --git a/app/src/styles.css b/app/src/styles.css index d262a472..89337e97 100644 --- a/app/src/styles.css +++ b/app/src/styles.css @@ -381,3 +381,10 @@ transform: translateY(-6px); /* slight lift to clear editor padding */ } } + +/* Remove focus outlines inside conflict diff viewer (Monaco defaults to a blue outline) */ +.conflict-diff .monaco-diff-editor *:focus, +.conflict-diff .monaco-diff-editor .synthetic-focus, +.conflict-diff .monaco-editor { + outline: none !important; +} From 4788edf1b46fd000657cf58b232ad76145c971a9 Mon Sep 17 00:00:00 2001 From: munenick Date: Wed, 10 Dec 2025 10:38:50 +0900 Subject: [PATCH 03/16] refactor: pull --- .../202701010001_create_git_pull_sessions.sql | 13 + api/openapi/openapi.json | 2 +- api/src/application/dto/git.rs | 18 +- .../ports/git_pull_session_repository.rs | 10 + api/src/application/ports/git_workspace.rs | 20 + api/src/application/ports/mod.rs | 1 + api/src/application/services/git.rs | 134 +++++- api/src/application/services/git_rebuild.rs | 33 ++ api/src/bin/export-openapi.rs | 5 + api/src/bin/refmd.rs | 48 ++ .../git_pull_session_repository_sqlx.rs | 74 +++ api/src/infrastructure/db/repositories/mod.rs | 1 + api/src/infrastructure/git/workspace.rs | 197 ++++++-- api/src/main.rs | 10 + api/src/presentation/http/git.rs | 434 ++++++++++++++++- app/src/entities/git/api/index.ts | 11 +- app/src/features/edit-document/ui/Editor.tsx | 11 + .../edit-document/ui/EditorLayout.tsx | 156 +++++- .../git-sync/lib/git-conflict-store.ts | 67 ++- .../features/git-sync/ui/git-pull-dialog.tsx | 13 +- .../features/git-sync/ui/git-sync-button.tsx | 136 ++++-- app/src/shared/api/client/sdk.gen.ts | 70 ++- app/src/shared/api/client/types.gen.ts | 31 ++ app/src/widgets/document/DocumentPage.tsx | 451 +++++++++++++++--- app/src/widgets/sidebar/FileTree.tsx | 74 ++- 25 files changed, 1860 insertions(+), 160 deletions(-) create mode 100644 api/migrations/202701010001_create_git_pull_sessions.sql create mode 100644 api/src/application/ports/git_pull_session_repository.rs create mode 100644 api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs diff --git a/api/migrations/202701010001_create_git_pull_sessions.sql b/api/migrations/202701010001_create_git_pull_sessions.sql new file mode 100644 index 00000000..5f4bffcb --- /dev/null +++ b/api/migrations/202701010001_create_git_pull_sessions.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS git_pull_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + workspace_id UUID NOT NULL, + status TEXT NOT NULL DEFAULT 'pending', -- pending | resolving | merged | stale + conflicts JSONB NOT NULL DEFAULT '[]'::jsonb, + resolutions JSONB NOT NULL DEFAULT '[]'::jsonb, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + base_commit BYTEA NULL, + remote_commit BYTEA NULL +); + +CREATE INDEX IF NOT EXISTS idx_git_pull_sessions_workspace ON git_pull_sessions(workspace_id, updated_at DESC); diff --git a/api/openapi/openapi.json b/api/openapi/openapi.json index ccde2efd..1abe5e61 100644 --- a/api/openapi/openapi.json +++ b/api/openapi/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} +{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} diff --git a/api/src/application/dto/git.rs b/api/src/application/dto/git.rs index 9041992e..b35a0f32 100644 --- a/api/src/application/dto/git.rs +++ b/api/src/application/dto/git.rs @@ -92,7 +92,7 @@ pub struct GitignoreUpdateDto { pub patterns: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GitPullResolutionDto { pub path: String, /// one of: ours, theirs, custom_text @@ -105,13 +105,14 @@ pub struct GitPullRequestDto { pub resolutions: Vec, } -#[derive(Debug, Clone)] +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] pub struct GitPullConflictItemDto { pub path: String, pub is_binary: bool, pub ours: Option, pub theirs: Option, pub base: Option, + pub document_id: Option, } #[derive(Debug, Clone)] @@ -121,4 +122,17 @@ pub struct GitPullResultDto { pub files_changed: u32, pub commit_hash: Option, pub conflicts: Option>, + pub base_commit: Option>, + pub remote_commit: Option>, +} + +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct GitPullSessionDto { + pub id: uuid::Uuid, + pub workspace_id: uuid::Uuid, + pub status: String, + pub conflicts: Vec, + pub resolutions: Vec, + pub base_commit: Option>, + pub remote_commit: Option>, } diff --git a/api/src/application/ports/git_pull_session_repository.rs b/api/src/application/ports/git_pull_session_repository.rs new file mode 100644 index 00000000..1a330926 --- /dev/null +++ b/api/src/application/ports/git_pull_session_repository.rs @@ -0,0 +1,10 @@ +use async_trait::async_trait; +use uuid::Uuid; + +use crate::application::dto::git::GitPullSessionDto; + +#[async_trait] +pub trait GitPullSessionRepository: Send + Sync { + async fn upsert(&self, session: GitPullSessionDto) -> anyhow::Result<()>; + async fn get(&self, workspace_id: Uuid, id: Uuid) -> anyhow::Result>; +} diff --git a/api/src/application/ports/git_workspace.rs b/api/src/application/ports/git_workspace.rs index 52a65ff7..d2f50178 100644 --- a/api/src/application/ports/git_workspace.rs +++ b/api/src/application/ports/git_workspace.rs @@ -38,6 +38,26 @@ pub trait GitWorkspacePort: Send + Sync { req: &GitPullRequestDto, cfg: &UserGitCfg, ) -> anyhow::Result; + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>>; + async fn remote_head( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result>>; + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result; + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result; + + /// Build a synthetic commit representing current workspace state (used for merges with dirty workspaces). + fn build_synthetic_commit( + &self, + workspace_id: Uuid, + repo: &git2::Repository, + remote_oid: git2::Oid, + ) -> anyhow::Result; async fn check_remote( &self, diff --git a/api/src/application/ports/mod.rs b/api/src/application/ports/mod.rs index 68e44a05..4134b849 100644 --- a/api/src/application/ports/mod.rs +++ b/api/src/application/ports/mod.rs @@ -10,6 +10,7 @@ pub mod git_rebuild_job_queue; pub mod git_repository; pub mod git_storage; pub mod git_workspace; +pub mod git_pull_session_repository; pub mod gitignore_port; pub mod health_probe; pub mod linkgraph_repository; diff --git a/api/src/application/services/git.rs b/api/src/application/services/git.rs index 9267740d..d8eb2b15 100644 --- a/api/src/application/services/git.rs +++ b/api/src/application/services/git.rs @@ -4,12 +4,13 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitConfigDto, GitRemoteCheckDto, GitStatusDto, GitSyncRequestDto, - GitSyncResponseDto, GitignoreUpdateDto, GitPullRequestDto, GitPullResultDto, - UpsertGitConfigInput, + GitChangeItem, GitCommitInfo, GitConfigDto, GitPullConflictItemDto, GitRemoteCheckDto, + GitStatusDto, GitSyncRequestDto, GitSyncResponseDto, GitignoreUpdateDto, GitPullRequestDto, + GitPullResultDto, GitPullSessionDto, UpsertGitConfigInput, }; use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::files_repository::FilesRepository; +use crate::application::ports::git_pull_session_repository::GitPullSessionRepository; use crate::application::ports::git_repository::GitRepository; use crate::application::ports::git_workspace::GitWorkspacePort; use crate::application::ports::gitignore_port::GitignorePort; @@ -39,6 +40,7 @@ pub struct GitService { docs: Arc, gitignore: Arc, workspace: Arc, + pull_sessions: Arc, } impl GitService { @@ -50,6 +52,7 @@ impl GitService { docs: Arc, gitignore: Arc, workspace: Arc, + pull_sessions: Arc, ) -> Self { Self { repo, @@ -58,6 +61,7 @@ impl GitService { docs, gitignore, workspace, + pull_sessions, } } @@ -327,7 +331,7 @@ impl GitService { workspace: self.workspace.as_ref(), repo: self.repo.as_ref(), }; - uc.execute(workspace_id, req).await.map_err(|err| { + let mut dto = uc.execute(workspace_id, req).await.map_err(|err| { let msg = err.to_string(); if msg.contains("pending changes") { ServiceError::BadRequest("workspace_has_pending_changes") @@ -338,6 +342,126 @@ impl GitService { } else { ServiceError::from(err) } - }) + })?; + + if let Some(conflicts) = dto.conflicts.take() { + dto.conflicts = Some(self.attach_conflict_documents(workspace_id, conflicts).await?); + } + + Ok(dto) + } + + async fn attach_conflict_documents( + &self, + workspace_id: Uuid, + conflicts: Vec, + ) -> Result, ServiceError> { + let mut out = Vec::with_capacity(conflicts.len()); + let docs = self.docs.list_workspace_documents(workspace_id).await.map_err(ServiceError::from)?; + + let normalize = |path: &str| path.trim_start_matches("./").trim_start_matches('/').to_string(); + + for mut conflict in conflicts { + if conflict.document_id.is_some() { + out.push(conflict); + continue; + } + let candidate = normalize(&conflict.path); + + let mut matched = None; + for doc in docs.iter() { + let mut paths: Vec = Vec::new(); + if let Some(p) = doc.path.as_ref() { + let norm = normalize(p); + if !norm.is_empty() { + paths.push(norm); + } + } + let desired = normalize(&doc.desired_path); + if !desired.is_empty() { + paths.push(desired); + } + + if paths.iter().any(|p| candidate == *p || candidate.ends_with(&format!("/{p}")) || p.ends_with(&candidate)) { + matched = Some(doc.id); + break; + } + } + + conflict.document_id = matched; + if let Some(doc_id) = matched { + if let Some(doc) = docs.iter().find(|d| d.id == doc_id) { + conflict.path = doc.desired_path.clone(); + } + } + out.push(conflict); + } + + Ok(out) + } + + pub async fn start_pull_session( + &self, + workspace_id: Uuid, + ) -> Result { + self + .pull_repository(workspace_id, GitPullRequestDto { resolutions: Vec::new() }) + .await + } + + pub async fn save_pull_session(&self, session: GitPullSessionDto) -> Result<(), ServiceError> { + self.pull_sessions.upsert(session).await.map_err(ServiceError::from) + } + + pub async fn load_pull_session( + &self, + workspace_id: Uuid, + id: Uuid, + ) -> Result, ServiceError> { + self.pull_sessions.get(workspace_id, id).await.map_err(ServiceError::from) + } + + pub async fn pull_session_is_stale( + &self, + workspace_id: Uuid, + session: &GitPullSessionDto, + ) -> Result { + let cfg = self + .repo + .load_user_git_cfg(workspace_id) + .await + .map_err(ServiceError::from)?; + let Some(cfg) = cfg else { + return Ok(true); + }; + + if let Some(saved_base) = session.base_commit.as_ref() { + if let Some(current_head) = self + .workspace + .head_commit(workspace_id) + .await + .map_err(ServiceError::from)? + { + // Only mark stale if the recorded base commit diverges from the latest committed head. + if saved_base != ¤t_head { + return Ok(true); + } + } + } + + if let Some(saved_remote) = session.remote_commit.as_ref() { + if let Some(current_remote) = self + .workspace + .remote_head(workspace_id, &cfg) + .await + .map_err(ServiceError::from)? + { + if saved_remote != ¤t_remote { + return Ok(true); + } + } + } + + Ok(false) } } diff --git a/api/src/application/services/git_rebuild.rs b/api/src/application/services/git_rebuild.rs index 6b71b6d3..537bf15c 100644 --- a/api/src/application/services/git_rebuild.rs +++ b/api/src/application/services/git_rebuild.rs @@ -352,6 +352,39 @@ mod tests { reason: None, }) } + + async fn head_commit(&self, _workspace_id: Uuid) -> anyhow::Result>> { + Ok(None) + } + + async fn remote_head( + &self, + _workspace_id: Uuid, + _cfg: &crate::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result>> { + Ok(None) + } + + async fn has_pending_changes(&self, _workspace_id: Uuid) -> anyhow::Result { + Ok(false) + } + + async fn drift_since_commit( + &self, + _workspace_id: Uuid, + _base_commit: &[u8], + ) -> anyhow::Result { + Ok(false) + } + + fn build_synthetic_commit( + &self, + _workspace_id: Uuid, + _repo: &git2::Repository, + _remote_oid: git2::Oid, + ) -> anyhow::Result { + anyhow::bail!("not supported") + } } struct RecordingJobQueue { diff --git a/api/src/bin/export-openapi.rs b/api/src/bin/export-openapi.rs index 9aacf4db..d01e2891 100644 --- a/api/src/bin/export-openapi.rs +++ b/api/src/bin/export-openapi.rs @@ -77,6 +77,10 @@ use utoipa::OpenApi; git::get_commit_diff, git::sync_now, git::pull_repository, + git::start_pull_session, + git::get_pull_session, + git::resolve_pull_session, + git::finalize_pull_session, git::init_repository, git::deinit_repository, git::ignore_document, @@ -184,6 +188,7 @@ use utoipa::OpenApi; git::GitSyncResponse, git::GitPullRequest, git::GitPullResponse, + git::GitPullSessionResponse, git::GitPullResolution, git::GitPullConflictItem, git::GitChangeItem, diff --git a/api/src/bin/refmd.rs b/api/src/bin/refmd.rs index 1b392b59..37dace60 100644 --- a/api/src/bin/refmd.rs +++ b/api/src/bin/refmd.rs @@ -601,6 +601,54 @@ impl GitWorkspacePort for CliGitWorkspace { bail!("pull not supported in refmd CLI"); } + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>> { + Ok(self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.commit_id)) + } + + async fn remote_head( + &self, + _workspace_id: Uuid, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result>> { + Ok(None) + } + + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result { + let dirty_rows = self.fetch_dirty(workspace_id).await?; + Ok(!dirty_rows.is_empty()) + } + + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result { + // CLI helper: fallback to dirty check when full state comparison is not available. + if self.has_pending_changes(workspace_id).await? { + return Ok(true); + } + // If the base commit is not the latest, consider it stale. + let latest = self.latest_commit_meta(workspace_id).await?; + if let Some(meta) = latest { + if meta.commit_id.as_slice() != base_commit { + return Ok(true); + } + } + Ok(false) + } + + fn build_synthetic_commit( + &self, + _workspace_id: Uuid, + _repo: &git2::Repository, + _remote_oid: git2::Oid, + ) -> anyhow::Result { + anyhow::bail!("not supported in CLI") + } + async fn check_remote( &self, _workspace_id: Uuid, diff --git a/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs new file mode 100644 index 00000000..36d39f72 --- /dev/null +++ b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs @@ -0,0 +1,74 @@ +use async_trait::async_trait; +use sqlx::types::Json; +use sqlx::{PgPool, Row}; +use uuid::Uuid; + +use crate::application::dto::git::{GitPullConflictItemDto, GitPullResolutionDto, GitPullSessionDto}; +use crate::application::ports::git_pull_session_repository::GitPullSessionRepository; + +pub struct GitPullSessionRepositorySqlx { + pool: PgPool, +} + +impl GitPullSessionRepositorySqlx { + pub fn new(pool: PgPool) -> Self { + Self { pool } + } +} + +#[async_trait] +impl GitPullSessionRepository for GitPullSessionRepositorySqlx { + async fn upsert(&self, session: GitPullSessionDto) -> anyhow::Result<()> { + let conflicts: Vec = session.conflicts; + let resolutions: Vec = session.resolutions; + sqlx::query( + r#"INSERT INTO git_pull_sessions (id, workspace_id, status, conflicts, resolutions, created_at, updated_at, base_commit, remote_commit) + VALUES ($1, $2, $3, $4, $5, now(), now(), $6, $7) + ON CONFLICT (id) DO UPDATE SET + status = EXCLUDED.status, + conflicts = EXCLUDED.conflicts, + resolutions = EXCLUDED.resolutions, + base_commit = EXCLUDED.base_commit, + remote_commit = EXCLUDED.remote_commit, + updated_at = now()"#, + ) + .bind(session.id) + .bind(session.workspace_id) + .bind(session.status) + .bind(Json(conflicts)) + .bind(Json(resolutions)) + .bind(session.base_commit.clone()) + .bind(session.remote_commit.clone()) + .execute(&self.pool) + .await?; + Ok(()) + } + + async fn get(&self, workspace_id: Uuid, id: Uuid) -> anyhow::Result> { + let row = sqlx::query( + r#"SELECT id, workspace_id, status, conflicts, resolutions, base_commit, remote_commit FROM git_pull_sessions + WHERE id = $1 AND workspace_id = $2"#, + ) + .bind(id) + .bind(workspace_id) + .fetch_optional(&self.pool) + .await?; + + let Some(row) = row else { return Ok(None); }; + let conflicts: Vec = row + .get::>, _>("conflicts") + .0; + let resolutions: Vec = row + .get::>, _>("resolutions") + .0; + Ok(Some(GitPullSessionDto { + id, + workspace_id, + status: row.get::("status"), + conflicts, + resolutions, + base_commit: row.get::>, _>("base_commit"), + remote_commit: row.get::>, _>("remote_commit"), + })) + } +} diff --git a/api/src/infrastructure/db/repositories/mod.rs b/api/src/infrastructure/db/repositories/mod.rs index 72ac2c11..2841954e 100644 --- a/api/src/infrastructure/db/repositories/mod.rs +++ b/api/src/infrastructure/db/repositories/mod.rs @@ -3,6 +3,7 @@ pub mod api_token_repository_sqlx; pub mod document_repository_sqlx; pub mod document_snapshot_archive_repository_sqlx; pub mod files_repository_sqlx; +pub mod git_pull_session_repository_sqlx; pub mod git_repository_sqlx; pub mod linkgraph_repository_sqlx; pub mod plugin_installation_repository_sqlx; diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index c373cbb0..f2f4ede8 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::{self, ErrorKind, Write}; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::sync::Arc; use anyhow::{Context, anyhow}; @@ -1989,6 +1989,7 @@ impl GitWorkspacePort for GitWorkspaceService { .map(|m| m.file_hash_index.clone()) .unwrap_or_default(); let previous_index = base_index.clone(); + let base_commit = latest_meta.as_ref().map(|m| m.commit_id.clone()); let temp_dir = TempDirBuilder::new() .prefix("git-pull-") @@ -2015,10 +2016,13 @@ impl GitWorkspacePort for GitWorkspaceService { files_changed: 0, commit_hash: None, conflicts: None, + base_commit: base_commit.clone(), + remote_commit: None, }); }; head }; + let remote_commit = Some(remote_oid.as_bytes().to_vec()); let local_oid = latest_meta .as_ref() @@ -2034,6 +2038,8 @@ impl GitWorkspacePort for GitWorkspaceService { drift_detected = true; } } + + // Do not bail on drift: we preserve workspace edits by synthesizing an "ours" commit below. if !drift_detected { for path in base_index.keys() { if !current_index.contains_key(path) { @@ -2137,6 +2143,7 @@ impl GitWorkspacePort for GitWorkspaceService { ours, theirs, base, + document_id: None, }); } // If commit IDs differ but no file-level diff was detected (should be rare), @@ -2150,6 +2157,7 @@ impl GitWorkspacePort for GitWorkspaceService { ours: None, theirs: None, base: None, + document_id: None, }); } } else { @@ -2160,55 +2168,58 @@ impl GitWorkspacePort for GitWorkspaceService { ours: None, theirs: None, base: None, + document_id: None, }); } } let remote_changes = !remote_conflicts.is_empty(); + let requires_resolution = remote_changes && drift_detected; - // If remote has changes and no resolutions are provided, return conflicts and do not apply. - if remote_changes && req.resolutions.is_empty() { + // If remote has changes conflicting with local drift and no resolutions are provided, ask client to resolve. + if requires_resolution && req.resolutions.is_empty() { return Ok(GitPullResultDto { success: false, message: "conflicts detected".to_string(), files_changed: 0, commit_hash: None, conflicts: Some(remote_conflicts), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), }); } - // If local state drifted and remote has changes, do not apply; surface conflicts only. - if drift_detected && remote_changes { - return Ok(GitPullResultDto { - success: false, - message: "conflicts detected".to_string(), - files_changed: 0, - commit_hash: None, - conflicts: Some(remote_conflicts), - }); - } + // Allow pull even when dirty changes exist; the current workspace state is treated as "ours". + // Validation for concurrent edits is handled later by conflict resolution. // If remote contains local, treat as fast-forward. // If remote contains local and remote has changes, return conflicts (no auto-apply). if let Some(local_oid_val) = local_oid { - if repo.graph_descendant_of(remote_oid, local_oid_val)? && remote_changes { + if repo.graph_descendant_of(remote_oid, local_oid_val)? + && requires_resolution + && req.resolutions.is_empty() + { return Ok(GitPullResultDto { success: false, message: "conflicts detected".to_string(), files_changed: 0, commit_hash: None, conflicts: Some(remote_conflicts), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), }); } } // Diverged: merge local into remote (linear, parent = remote) - let Some(local_oid_val) = local_oid else { + let Some(_local_oid_val) = local_oid else { anyhow::bail!("no local commit to merge"); }; let (meta, pack_bytes, merged_snapshots, commit_hex) = { - let local_commit = repo.find_commit(local_oid_val)?; - let remote_commit = repo.find_commit(remote_oid)?; - let index = repo.merge_commits(&local_commit, &remote_commit, None)?; + // Build a synthetic "ours" commit from the current workspace state so dirty edits are preserved. + let synthetic_ours = self.build_synthetic_commit(workspace_id, &repo, remote_oid)?; + let ours_commit = repo.find_commit(synthetic_ours)?; + let remote_commit_obj = repo.find_commit(remote_oid)?; + let index = repo.merge_commits(&ours_commit, &remote_commit_obj, None)?; let conflict_items = collect_conflicts(&repo, &index)?; if !conflict_items.is_empty() && req.resolutions.is_empty() { @@ -2218,6 +2229,8 @@ impl GitWorkspacePort for GitWorkspaceService { files_changed: 0, commit_hash: None, conflicts: Some(conflict_items), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), }); } @@ -2282,21 +2295,31 @@ impl GitWorkspacePort for GitWorkspaceService { ); } - for (path, ours_bytes, theirs_bytes, _base) in conflict_entries { - let Some(resolution) = resolution_map.get(&path) else { - return Ok(GitPullResultDto { - success: false, - message: "conflicts detected".to_string(), - files_changed: 0, - commit_hash: None, - conflicts: Some(collect_conflicts(&repo, &index)?), + let mut unresolved: Vec = Vec::new(); + + for (path, ours_bytes, theirs_bytes, base_bytes) in conflict_entries { + let resolution = resolution_map.get(&path); + if resolution.is_none() { + let (ours_txt, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (theirs_txt, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (base_txt, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + unresolved.push(GitPullConflictItemDto { + path: path.clone(), + is_binary: ours_bin || theirs_bin || base_bin, + ours: ours_txt, + theirs: theirs_txt, + base: base_txt, + document_id: None, }); - }; - let selected_bytes = match resolution.choice.as_str() { + continue; + } + + let res = *resolution.unwrap(); + let selected_bytes = match res.choice.as_str() { "ours" => ours_bytes.clone(), "theirs" => theirs_bytes.clone(), "custom_text" => { - Some(resolution.content.clone().unwrap_or_default().into_bytes()) + Some(res.content.clone().unwrap_or_default().into_bytes()) } other => anyhow::bail!("unsupported resolution choice {other}"), } @@ -2313,6 +2336,18 @@ impl GitWorkspacePort for GitWorkspaceService { ); } + if !unresolved.is_empty() { + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(unresolved), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); + } + // Build tree from merged snapshots without async work let mut entry_map: BTreeMap> = BTreeMap::new(); for (path, snap) in merged_snapshots.iter() { @@ -2333,7 +2368,7 @@ impl GitWorkspacePort for GitWorkspaceService { &sig, "Merge remote changes", &tree, - &[&remote_commit], + &[&remote_commit_obj], )?; let mut file_hash_index: HashMap = HashMap::new(); @@ -2436,9 +2471,110 @@ impl GitWorkspacePort for GitWorkspaceService { files_changed, commit_hash: Some(commit_hex), conflicts: None, + base_commit, + remote_commit, }) } + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>> { + Ok(self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.commit_id)) + } + + async fn remote_head( + &self, + workspace_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result>> { + let state = self.load_repository_state(workspace_id).await?; + let Some((initialized, branch_default)) = state else { + anyhow::bail!("repository not initialized"); + }; + if !initialized { + anyhow::bail!("repository not initialized"); + } + if cfg.repository_url.is_empty() { + anyhow::bail!("remote not configured"); + } + let branch = if cfg.branch_name.is_empty() { + branch_default + } else { + cfg.branch_name.clone() + }; + let temp_dir = TempDirBuilder::new() + .prefix("git-remote-head-") + .tempdir() + .map_err(|e| anyhow!(e))?; + let repo = Repository::init_bare(temp_dir.path())?; + let head = fetch_remote_head(&repo, cfg, &branch)?; + Ok(head.map(|oid| oid.as_bytes().to_vec())) + } + + async fn has_pending_changes(&self, workspace_id: Uuid) -> anyhow::Result { + let dirty_rows = self.fetch_dirty(workspace_id).await?; + Ok(!dirty_rows.is_empty()) + } + + // Build a synthetic commit that represents the current workspace state, so dirty edits participate in merge. + fn build_synthetic_commit( + &self, + workspace_id: Uuid, + repo: &Repository, + remote_oid: git2::Oid, + ) -> anyhow::Result { + // Collect current workspace state into blobs and a tree. + let handle = tokio::runtime::Handle::current(); + let current_state = handle.block_on(self.collect_current_state(workspace_id))?; + + let mut tree_builder = repo.treebuilder(None)?; + for (path, snapshot) in current_state.iter() { + let bytes = handle.block_on(self.snapshot_bytes(snapshot))?; + let blob_oid = repo.blob(&bytes)?; + let mode = 0o100644; + tree_builder.insert(Path::new(path), blob_oid, mode)?; + } + let tree_oid = tree_builder.write()?; + let tree = repo.find_tree(tree_oid)?; + + // Create a synthetic commit with remote as parent to anchor the merge base. + let sig = repo.signature()?; + let commit_oid = repo.commit( + Some("refs/heads/synthetic-workspace"), + &sig, + &sig, + "workspace-state", + &tree, + &[&repo.find_commit(remote_oid)?], + )?; + Ok(commit_oid) + } + + async fn drift_since_commit( + &self, + workspace_id: Uuid, + base_commit: &[u8], + ) -> anyhow::Result { + let Some(meta) = self.commit_meta_by_id(workspace_id, base_commit).await? else { + return Ok(true); + }; + let base_index = meta.file_hash_index; + let current_state = self.collect_current_state(workspace_id).await?; + if base_index.len() != current_state.len() { + return Ok(true); + } + for (path, snapshot) in current_state.into_iter() { + let Some(base_hash) = base_index.get(&path) else { + return Ok(true); + }; + if base_hash != &snapshot.hash { + return Ok(true); + } + } + Ok(false) + } + async fn check_remote( &self, workspace_id: Uuid, @@ -2781,6 +2917,7 @@ fn collect_conflicts( ours, theirs, base, + document_id: None, }); } Ok(out) diff --git a/api/src/main.rs b/api/src/main.rs index 81819b37..2ab544a1 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -148,6 +148,10 @@ const SESSION_CLEANUP_BATCH_SIZE: i64 = 500; api::presentation::http::git::get_working_diff, api::presentation::http::git::get_commit_diff, api::presentation::http::git::sync_now, + api::presentation::http::git::start_pull_session, + api::presentation::http::git::get_pull_session, + api::presentation::http::git::resolve_pull_session, + api::presentation::http::git::finalize_pull_session, api::presentation::http::git::init_repository, api::presentation::http::git::deinit_repository, api::presentation::http::git::ignore_document, @@ -556,6 +560,11 @@ async fn main() -> anyhow::Result<()> { cfg.encryption_key.clone(), ), ); + let git_pull_sessions = Arc::new( + api::infrastructure::db::repositories::git_pull_session_repository_sqlx::GitPullSessionRepositorySqlx::new( + pool.clone(), + ), + ); let auto_archive_interval = Duration::from_secs(cfg.snapshot_archive_interval_secs); let mut local_hub: Option = None; let (realtime_engine, snapshot_service_arc): ( @@ -707,6 +716,7 @@ async fn main() -> anyhow::Result<()> { document_repo.clone(), gitignore_port.clone(), git_workspace.clone(), + git_pull_sessions.clone(), )); if cfg.git_rebuild_enabled { let rebuild_service = Arc::new(GitRebuildService::new( diff --git a/api/src/presentation/http/git.rs b/api/src/presentation/http/git.rs index ff173aab..3ab6eb72 100644 --- a/api/src/presentation/http/git.rs +++ b/api/src/presentation/http/git.rs @@ -13,6 +13,7 @@ use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitPullRequestDto, GitPullResolutionDto, GitStatusDto, GitSyncRequestDto, GitignoreUpdateDto, UpsertGitConfigInput, + GitPullSessionDto, }; use crate::application::services::errors::ServiceError; use crate::domain::workspaces::permissions::{PERM_GIT_CONFIGURE, PERM_GIT_INIT, PERM_GIT_SYNC}; @@ -21,6 +22,7 @@ use crate::presentation::http::workspace_scope; use tracing::error; use uuid::Uuid; + // Uses AppContext as router state pub fn routes(ctx: AppContext) -> Router { @@ -38,6 +40,10 @@ pub fn routes(ctx: AppContext) -> Router { .route("/git/diff/commits/:from/:to", get(get_commit_diff)) .route("/git/sync", post(sync_now)) .route("/git/pull", post(pull_repository)) + .route("/git/pull/start", post(start_pull_session)) + .route("/git/pull/session/:id", get(get_pull_session)) + .route("/git/pull/session/:id/resolve", post(resolve_pull_session)) + .route("/git/pull/session/:id/finalize", post(finalize_pull_session)) .route("/git/init", post(init_repository)) .route("/git/deinit", post(deinit_repository)) .route("/git/ignore/doc/:id", post(ignore_document)) @@ -152,7 +158,7 @@ pub struct UpdateGitConfigRequest { pub auto_sync: Option, } -#[derive(Debug, Serialize, Deserialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct GitPullResolution { pub path: String, pub choice: String, @@ -171,6 +177,7 @@ pub struct GitPullConflictItem { pub ours: Option, pub theirs: Option, pub base: Option, + pub document_id: Option, } impl From for GitPullConflictItem { @@ -181,6 +188,7 @@ impl From for GitPullConfl ours: value.ours, theirs: value.theirs, base: value.base, + document_id: value.document_id, } } } @@ -192,6 +200,40 @@ pub struct GitPullResponse { pub files_changed: i32, pub commit_hash: Option, pub conflicts: Option>, + pub git_status: Option, +} + +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitPullSessionResponse { + pub session_id: uuid::Uuid, + pub status: String, + pub conflicts: Vec, + pub resolutions: Vec, + pub message: Option, +} + +impl From for GitPullSessionResponse { + fn from(value: GitPullSessionDto) -> Self { + Self { + session_id: value.id, + status: value.status, + conflicts: value + .conflicts + .into_iter() + .map(Into::into) + .collect(), + resolutions: value + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: None, + } + } } #[utoipa::path(get, path = "/api/git/config", tag = "Git", responses((status = 200, body = Option)))] @@ -325,7 +367,7 @@ pub async fn delete_config( Ok(StatusCode::NO_CONTENT) } -#[derive(Debug, Serialize, ToSchema)] +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct GitStatus { pub repository_initialized: bool, pub has_remote: bool, @@ -646,6 +688,7 @@ pub async fn pull_repository( files_changed: 0, commit_hash: None, conflicts: None, + git_status: None, }; (status, body) }); @@ -667,10 +710,397 @@ pub async fn pull_repository( files_changed: dto.files_changed as i32, commit_hash: dto.commit_hash, conflicts: if has_conflicts { Some(conflicts) } else { None }, + git_status: None, + }), + )) +} + +#[utoipa::path( + post, + path = "/api/git/pull/start", + tag = "Git", + responses( + (status = 200, body = GitPullSessionResponse), + (status = 409, body = GitPullSessionResponse, description = "Conflicts detected") + ) +)] +pub async fn start_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let dto = match service.start_pull_session(workspace_id).await { + Ok(v) => v, + Err(err) => { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => + "Workspace has pending changes. Commit, sync, or discard them before pulling.".to_string(), + other => other.to_string(), + }; + let status = map_git_error(err); + return Ok(( + status, + Json(GitPullSessionResponse { + session_id: Uuid::nil(), + status: "error".to_string(), + conflicts: Vec::new(), + resolutions: Vec::new(), + message: Some(message), + }), + )); + } + }; + let conflicts = dto + .conflicts + .clone() + .unwrap_or_default() + .into_iter() + .map(Into::into) + .collect::>(); + let has_conflicts = !conflicts.is_empty(); + + let session_id = Uuid::new_v4(); + let _ = service + .save_pull_session(GitPullSessionDto { + id: session_id, + workspace_id, + status: if has_conflicts { "pending".to_string() } else { "merged".to_string() }, + conflicts: dto.conflicts.unwrap_or_default(), + resolutions: Vec::new(), + base_commit: dto.base_commit.clone(), + remote_commit: dto.remote_commit.clone(), + }) + .await; + let status = if has_conflicts { StatusCode::CONFLICT } else { StatusCode::OK }; + Ok(( + status, + Json(GitPullSessionResponse { + session_id, + status: if has_conflicts { "pending".to_string() } else { "merged".to_string() }, + conflicts, + resolutions: Vec::new(), + message: None, + }), + )) +} + +#[utoipa::path( + get, + path = "/api/git/pull/session/{id}", + tag = "Git", + responses((status = 200, body = GitPullSessionResponse)) +)] +pub async fn get_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let mut state = service + .load_pull_session(workspace_id, id) + .await + .map_err(|err| { + let status = map_git_error(err); + status + })? + .ok_or(StatusCode::NOT_FOUND)?; + if service + .pull_session_is_stale(workspace_id, &state) + .await + .map_err(map_git_error)? + { + state.status = "stale".to_string(); + let _ = service.save_pull_session(state.clone()).await; + } + Ok(Json(GitPullSessionResponse { + session_id: state.id, + status: state.status, + conflicts: state.conflicts.into_iter().map(Into::into).collect(), + resolutions: state + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: None, + })) +} + +#[utoipa::path( + post, + path = "/api/git/pull/session/{id}/resolve", + tag = "Git", + request_body = GitPullRequest, + responses( + (status = 200, body = GitPullSessionResponse), + (status = 409, body = GitPullSessionResponse) + ) +)] +pub async fn resolve_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, + Json(req): Json, +) -> Result<(StatusCode, Json), StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let existing_session = service + .load_pull_session(workspace_id, id) + .await + .map_err(|err| { + let status = map_git_error(err); + status + })? + .ok_or(StatusCode::NOT_FOUND)?; + if service + .pull_session_is_stale(workspace_id, &existing_session) + .await + .map_err(map_git_error)? + { + let mut stale = existing_session.clone(); + stale.status = "stale".to_string(); + let _ = service.save_pull_session(stale.clone()).await; + return Ok(( + StatusCode::CONFLICT, + Json(GitPullSessionResponse { + session_id: id, + status: "stale".to_string(), + conflicts: stale.conflicts.into_iter().map(Into::into).collect(), + resolutions: stale + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: Some("Pull session is stale. Please start a new pull.".to_string()), + }), + )); + } + + let resolutions = req.resolutions.unwrap_or_default(); + let dto = match service + .pull_repository( + workspace_id, + GitPullRequestDto { + resolutions: resolutions + .iter() + .cloned() + .map(|r| GitPullResolutionDto { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + }, + ) + .await + { + Ok(v) => v, + Err(err) => { + let message = match &err { + ServiceError::BadRequest("workspace_has_pending_changes") => + "Workspace has pending changes. Commit, sync, or discard them before pulling.".to_string(), + other => other.to_string(), + }; + let status = map_git_error(err); + return Ok(( + status, + Json(GitPullSessionResponse { + session_id: id, + status: "error".to_string(), + conflicts: existing_session + .conflicts + .into_iter() + .map(Into::into) + .collect(), + resolutions: existing_session + .resolutions + .into_iter() + .map(|r| GitPullResolution { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + message: Some(message), + }), + )); + } + }; + + let mut status_code = StatusCode::OK; + + let conflicts: Vec = dto + .conflicts + .clone() + .unwrap_or_default() + .into_iter() + .map(Into::into) + .collect(); + if !conflicts.is_empty() { + status_code = StatusCode::CONFLICT; + } + + let _ = service + .save_pull_session(GitPullSessionDto { + id, + workspace_id, + status: if conflicts.is_empty() { "merged".to_string() } else { "resolving".to_string() }, + conflicts: dto.conflicts.unwrap_or_default(), + resolutions: resolutions + .iter() + .cloned() + .map(|r| GitPullResolutionDto { + path: r.path, + choice: r.choice, + content: r.content, + }) + .collect(), + base_commit: dto.base_commit.clone(), + remote_commit: dto.remote_commit.clone(), + }) + .await; + + Ok(( + status_code, + Json(GitPullSessionResponse { + session_id: id, + status: if conflicts.is_empty() { "merged".to_string() } else { "resolving".to_string() }, + conflicts, + resolutions, + message: None, }), )) } +#[utoipa::path( + post, + path = "/api/git/pull/session/{id}/finalize", + tag = "Git", + responses((status = 200, body = GitPullResponse)) +)] +pub async fn finalize_pull_session( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_SYNC) + .await?; + + let service = ctx.git_service(); + let state = service + .load_pull_session(workspace_id, id) + .await + .map_err(|err| map_git_error(err))? + .ok_or(StatusCode::NOT_FOUND)?; + if service + .pull_session_is_stale(workspace_id, &state) + .await + .map_err(map_git_error)? + { + let mut stale = state.clone(); + stale.status = "stale".to_string(); + let _ = service.save_pull_session(stale.clone()).await; + return Ok(Json(GitPullResponse { + success: false, + message: "pull session stale".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(stale.conflicts.into_iter().map(Into::into).collect()), + git_status: None, + })); + } + if !state.conflicts.is_empty() { + return Ok(Json(GitPullResponse { + success: false, + message: "conflicts remaining".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(state.conflicts.into_iter().map(Into::into).collect()), + git_status: None, + })); + } + let git_status = service.get_status(workspace_id).await.map_err(map_git_error)?; + let _ = service + .save_pull_session(GitPullSessionDto { + id, + workspace_id, + status: "merged".to_string(), + conflicts: Vec::new(), + resolutions: state.resolutions.clone(), + base_commit: state.base_commit.clone(), + remote_commit: state.remote_commit.clone(), + }) + .await; + + Ok(Json(GitPullResponse { + success: true, + message: "merge completed".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: None, + git_status: Some(git_status.into()), + })) +} + #[derive(Debug, Serialize, ToSchema)] pub struct GitChangeItem { pub path: String, diff --git a/app/src/entities/git/api/index.ts b/app/src/entities/git/api/index.ts index 13009559..eb2d36cb 100644 --- a/app/src/entities/git/api/index.ts +++ b/app/src/entities/git/api/index.ts @@ -11,12 +11,17 @@ import { ignoreFolder as apiIgnoreFolder, initRepository as apiInitRepository, pullRepository as apiPullRepository, + startPullSession as apiStartPullSession, + getPullSession as apiGetPullSession, + resolvePullSession as apiResolvePullSession, + finalizePullSession as apiFinalizePullSession, syncNow as apiSyncNow, } from '@/shared/api' import type { GitChangesResponse, GitHistoryResponse, GitPullResponse, + GitPullSessionResponse, GitStatus, PullRepositoryData, TextDiffResult, @@ -60,9 +65,13 @@ export { apiDeinitRepository as deinitRepository, apiInitRepository as initRepository, apiPullRepository as pullRepository, + apiStartPullSession as startPullSession, + apiGetPullSession as getPullSession, + apiResolvePullSession as resolvePullSession, + apiFinalizePullSession as finalizePullSession, apiSyncNow as syncNow, apiIgnoreDocument as ignoreDocument, apiIgnoreFolder as ignoreFolder, } -export type { GitPullResponse, PullRepositoryData } +export type { GitPullResponse, GitPullSessionResponse, PullRepositoryData } diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index e330fda9..71fd652b 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -56,6 +56,13 @@ export type MarkdownEditorProps = { documentId: string readOnly?: boolean extraRight?: React.ReactNode + conflictControls?: React.ReactNode + conflictHunkWidgets?: Array<{ + id: string + line: number + choice?: 'ours' | 'theirs' + onChoose: (side: 'ours' | 'theirs') => void + }> conflictView?: { kind: 'text' | 'binary' original?: string @@ -83,6 +90,8 @@ export function MarkdownEditor(props: MarkdownEditorProps) { documentId, readOnly = false, extraRight, + conflictControls, + conflictHunkWidgets, conflictView, renderPreview, } = props @@ -565,6 +574,8 @@ export function MarkdownEditor(props: MarkdownEditorProps) { showVimStatusBar={isVimMode} uploadStatus={uploadStatus} renderPreview={renderPreview} + conflictControls={conflictControls} + conflictHunkWidgets={conflictHunkWidgets} conflictView={ conflictView ? { diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index 7553a8fc..a13aee44 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -1,7 +1,7 @@ +import { DiffEditor } from '@monaco-editor/react' import { AlertTriangle, Check, Loader2, SlidersHorizontal, X } from 'lucide-react' import type * as monacoNs from 'monaco-editor' -import { DiffEditor } from '@monaco-editor/react' -import { useCallback, useMemo, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' +import { useCallback, useMemo, useEffect, useRef, useState, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -42,6 +42,13 @@ export type EditorLayoutProps = { renderPreview?: (props: PreviewPaneProps) => ReactNode editorOverlay?: ReactNode editorBanner?: ReactNode + conflictControls?: ReactNode + conflictHunkWidgets?: Array<{ + id: string + line: number + choice?: 'ours' | 'theirs' + onChoose: (side: 'ours' | 'theirs') => void + }> conflictView?: { kind: 'text' | 'binary' original?: string @@ -85,8 +92,148 @@ export function EditorLayout({ renderPreview, editorOverlay, editorBanner, + conflictControls, + conflictHunkWidgets, conflictView, }: EditorLayoutProps) { + const diffEditorRef = useRef(null) + const monacoRef = useRef(null) + const [diffReady, setDiffReady] = useState(false) + const overlayNodesRef = useRef>({}) + const overlayDisposablesRef = useRef([]) + + useEffect(() => { + // cleanup helper + const cleanup = () => { + Object.values(overlayNodesRef.current).forEach((node) => node.remove()) + overlayNodesRef.current = {} + overlayDisposablesRef.current.forEach((d) => d.dispose()) + overlayDisposablesRef.current = [] + } + + const diff = diffEditorRef.current + const monacoInstance = monacoRef.current + if (!diff || !monacoInstance || !diffReady) { + cleanup() + return + } + const modified = diff.getModifiedEditor() + const model = modified?.getModel() + if (!modified || !model || !conflictHunkWidgets || conflictHunkWidgets.length === 0) { + cleanup() + return + } + + const host = + modified.getDomNode()?.querySelector('.overflow-guard') ?? + modified.getDomNode() ?? + document.createElement('div') + if (!host) { + cleanup() + return + } + if (host instanceof HTMLElement) { + const style = host.style + if (!style.position || style.position === 'static') { + style.position = 'relative' + } + } + + const isDark = typeof document !== 'undefined' && document.documentElement.classList.contains('dark') + const palette = { + ours: { + bg: isDark ? 'rgba(127,29,29,0.30)' : '#fef2f2', + bgActive: isDark ? 'rgba(185,28,28,0.55)' : '#fee2e2', + color: isDark ? '#fecdd3' : '#b91c1c', + }, + theirs: { + bg: isDark ? 'rgba(5,46,22,0.30)' : '#f0fdf4', + bgActive: isDark ? 'rgba(34,197,94,0.50)' : '#dcfce7', + color: isDark ? '#bbf7d0' : '#166534', + }, + } + + const createNode = (hunk: typeof conflictHunkWidgets[number]) => { + const node = document.createElement('div') + node.style.position = 'absolute' + node.style.display = 'inline-flex' + node.style.flexDirection = 'row' + node.style.alignItems = 'center' + node.style.gap = '8px' + node.style.padding = '2px 6px' + node.style.borderRadius = '10px' + node.style.background = 'transparent' + node.style.pointerEvents = 'auto' + node.style.whiteSpace = 'nowrap' + node.style.zIndex = '50' + + const makeBtn = (label: string, side: 'ours' | 'theirs') => { + const btn = document.createElement('button') + btn.textContent = label + btn.style.fontSize = '11px' + btn.style.padding = '4px 10px' + btn.style.borderRadius = '8px' + btn.style.border = 'none' + btn.style.cursor = 'pointer' + btn.style.lineHeight = '1' + btn.style.fontWeight = hunk.choice === side ? '700' : '500' + btn.style.display = 'inline-flex' + btn.style.alignItems = 'center' + btn.style.justifyContent = 'center' + const colors = side === 'ours' ? palette.ours : palette.theirs + btn.style.background = hunk.choice === side ? colors.bgActive : colors.bg + btn.style.color = colors.color + btn.onmousedown = (e) => { + e.preventDefault() + e.stopPropagation() + } + btn.onclick = (e) => { + e.preventDefault() + e.stopPropagation() + hunk.onChoose(side) + } + return btn + } + + node.appendChild(makeBtn('Keep Mine', 'ours')) + node.appendChild(makeBtn('Take Remote', 'theirs')) + return node + } + + conflictHunkWidgets.forEach((hunk) => { + const node = createNode(hunk) + overlayNodesRef.current[hunk.id] = node + host.appendChild(node) + }) + + const updatePositions = () => { + const layout = modified.getLayoutInfo() + const lineHeight = modified.getOption(monacoInstance.editor.EditorOption.lineHeight) + const rightInset = + (layout.minimap?.renderMinimap ? layout.minimap.minimapWidth : 0) + + layout.verticalScrollbarWidth + + layout.glyphMarginWidth + + 12 + conflictHunkWidgets.forEach((hunk) => { + const node = overlayNodesRef.current[hunk.id] + if (!node) return + const top = modified.getTopForLineNumber(Math.max(hunk.line, 1)) + lineHeight - 2 + node.style.top = `${top}px` + node.style.right = `${rightInset}px` + }) + } + + updatePositions() + + overlayDisposablesRef.current.push( + modified.onDidScrollChange(() => updatePositions()), + modified.onDidLayoutChange(() => updatePositions()), + modified.onDidChangeConfiguration(() => updatePositions()), + ) + + return cleanup + }, [conflictHunkWidgets, diffReady]) + const uploadStatusNode = (() => { if (uploadStatus.state === 'idle') return null let primary = '' @@ -261,9 +408,13 @@ export function EditorLayout({ original={conflictView.original ?? ''} modified={conflictView.modified ?? ''} beforeMount={(monacoInstance) => { + monacoRef.current = monacoInstance ensureRefmdThemes(monacoInstance) }} onMount={(editor, monacoInstance) => { + diffEditorRef.current = editor + monacoRef.current = monacoInstance + setDiffReady(true) monacoInstance.editor.setTheme(conflictView.theme ?? monacoTheme) const modified = editor.getModifiedEditor() const original = editor.getOriginalEditor() @@ -303,6 +454,7 @@ export function EditorLayout({ }} />
+ {conflictControls ?
{conflictControls}
: null}
) : conflictView && conflictView.kind === 'binary' ? (
diff --git a/app/src/features/git-sync/lib/git-conflict-store.ts b/app/src/features/git-sync/lib/git-conflict-store.ts index 458cec5d..4df5d0cc 100644 --- a/app/src/features/git-sync/lib/git-conflict-store.ts +++ b/app/src/features/git-sync/lib/git-conflict-store.ts @@ -1,18 +1,83 @@ -import type { GitPullConflictItem } from '@/shared/api' +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' export const GIT_CONFLICT_EVENT = 'refmd:git-conflicts-updated' let currentConflicts: GitPullConflictItem[] = [] +let currentResolutions: GitPullResolution[] = [] +let currentSessionId: string | null = null + +const STORAGE_CONFLICTS_KEY = 'refmd:git-conflicts' +const STORAGE_RESOLUTIONS_KEY = 'refmd:git-conflict-resolutions' +const STORAGE_SESSION_KEY = 'refmd:git-conflict-session' + +const loadFromStorage = (key: string): T[] => { + if (typeof window === 'undefined') return [] + try { + const raw = window.localStorage.getItem(key) + if (!raw) return [] + const parsed = JSON.parse(raw) + return Array.isArray(parsed) ? (parsed as T[]) : [] + } catch { + return [] + } +} + +const persistStorage = (key: string, value: unknown) => { + if (typeof window === 'undefined') return + try { + window.localStorage.setItem(key, JSON.stringify(value)) + } catch { + /* ignore */ + } +} + +// Hydrate initial state from storage when on client +if (typeof window !== 'undefined') { + currentConflicts = loadFromStorage(STORAGE_CONFLICTS_KEY) + currentResolutions = loadFromStorage(STORAGE_RESOLUTIONS_KEY) + const sid = loadFromStorage(STORAGE_SESSION_KEY) + currentSessionId = sid && sid.length ? sid[0] : null +} export const readConflicts = (): GitPullConflictItem[] => currentConflicts.slice() +export const readResolutions = (): GitPullResolution[] => currentResolutions.slice() +export const readSessionId = (): string | null => currentSessionId export const setConflicts = (conflicts: GitPullConflictItem[] | null | undefined) => { currentConflicts = Array.isArray(conflicts) ? conflicts.slice() : [] + persistStorage(STORAGE_CONFLICTS_KEY, currentConflicts) + // Clear resolutions if conflicts are cleared + if (!currentConflicts.length) { + setResolutions([]) + } if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent(GIT_CONFLICT_EVENT, { detail: currentConflicts })) } } +export const setResolutions = (resolutions: GitPullResolution[] | null | undefined) => { + currentResolutions = Array.isArray(resolutions) ? resolutions.slice() : [] + persistStorage(STORAGE_RESOLUTIONS_KEY, currentResolutions) +} + +export const clearResolutions = () => setResolutions([]) + +export const setSessionId = (sessionId: string | null) => { + currentSessionId = sessionId || null + persistStorage(STORAGE_SESSION_KEY, sessionId ? [sessionId] : []) +} + +export const clearSession = () => { + setSessionId(null) + clearAllConflicts() +} + +export const clearAllConflicts = () => { + setConflicts([]) + setResolutions([]) + setSessionId(null) +} + export const subscribeConflicts = (handler: (items: GitPullConflictItem[]) => void) => { if (typeof window === 'undefined') return () => {} const listener = (event: Event) => { diff --git a/app/src/features/git-sync/ui/git-pull-dialog.tsx b/app/src/features/git-sync/ui/git-pull-dialog.tsx index eedd860f..b9bade8e 100644 --- a/app/src/features/git-sync/ui/git-pull-dialog.tsx +++ b/app/src/features/git-sync/ui/git-pull-dialog.tsx @@ -1,11 +1,10 @@ -import React from 'react' import { AlertTriangle, Loader2 } from 'lucide-react' +import React from 'react' +import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' import { Button } from '@/shared/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' -import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' - type Props = { open: boolean onOpenChange: (open: boolean) => void @@ -14,9 +13,10 @@ type Props = { onResolve: (resolutions: GitPullResolution[]) => void onRetry?: () => void emptyWarning?: boolean + sessionId?: string | null } -export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onResolve, onRetry, emptyWarning }: Props) { +export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onResolve, onRetry, emptyWarning, sessionId }: Props) { const [choices, setChoices] = React.useState>({}) React.useEffect(() => { @@ -53,6 +53,11 @@ export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading Remote is ahead. Choose whether to keep your version or the remote version for each file, then apply. + {sessionId ? ( +
+ Session ID: {sessionId} +
+ ) : null}
diff --git a/app/src/features/git-sync/ui/git-sync-button.tsx b/app/src/features/git-sync/ui/git-sync-button.tsx index 4093eed2..7a132936 100644 --- a/app/src/features/git-sync/ui/git-sync-button.tsx +++ b/app/src/features/git-sync/ui/git-sync-button.tsx @@ -1,9 +1,11 @@ import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' import { Link } from '@tanstack/react-router' import { AlertCircle, CheckCircle, Eye, FileX, GitCommit, History, Loader2, RefreshCw, Settings } from 'lucide-react' -import { useMemo, useState, useCallback } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' +import type { GitPullConflictItem, GitPullResolution, GitPullSessionResponse } from '@/shared/api' +import { ApiError } from '@/shared/api' import { useIsMobile } from '@/shared/hooks/use-mobile' import { overlayMenuClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' @@ -11,11 +13,10 @@ import { Button } from '@/shared/ui/button' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from '@/shared/ui/dropdown-menu' import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' -import { getStatus, getConfig, syncNow, initRepository, pullRepository } from '@/entities/git' -import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' -import { ApiError } from '@/shared/api' +import { getStatus, getConfig, syncNow, initRepository, startPullSession, resolvePullSession, finalizePullSession, getPullSession } from '@/entities/git' + +import { setConflicts as setGlobalConflicts, readConflicts, clearResolutions, setSessionId, readSessionId, clearSession, readResolutions } from '@/features/git-sync/lib/git-conflict-store' -import { setConflicts as setGlobalConflicts, readConflicts } from '@/features/git-sync/lib/git-conflict-store' import GitChangesDialog from './git-changes-dialog' import GitHistoryDialog from './git-history-dialog' import GitPullDialog from './git-pull-dialog' @@ -33,6 +34,18 @@ function useGitSyncController() { const [showPullDialog, setShowPullDialog] = useState(false) const [pullConflicts, setPullConflicts] = useState(() => readConflicts()) const [emptyConflictWarning, setEmptyConflictWarning] = useState(false) + const [polling, setPolling] = useState(false) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) + + useEffect(() => { + const handler = () => setSessionIdState(readSessionId()) + window.addEventListener('storage', handler) + window.addEventListener('refmd:git-conflicts-updated', handler as any) + return () => { + window.removeEventListener('storage', handler) + window.removeEventListener('refmd:git-conflicts-updated', handler as any) + } + }, []) const extractConflicts = useCallback((value: unknown): GitPullConflictItem[] => { const fromArray = (arr: unknown): GitPullConflictItem[] => (Array.isArray(arr) ? (arr as GitPullConflictItem[]) : []) @@ -54,6 +67,9 @@ function useGitSyncController() { } setPullConflicts(safeList) setGlobalConflicts(safeList) + if (safeList.length === 0) { + clearResolutions() + } if (safeList.length > 0) { setEmptyConflictWarning(false) } @@ -112,51 +128,83 @@ function useGitSyncController() { }) const pullMutation = useMutation({ - mutationFn: (payload?: { resolutions?: GitPullResolution[] }) => - pullRepository({ requestBody: { resolutions: payload?.resolutions ?? [] } }), - onSuccess: (res) => { - const extracted = extractConflicts(res) - const hasConflicts = extracted.length > 0 - if (!res.success) { - updateConflicts(hasConflicts ? extracted : readConflicts()) - setShowPullDialog(true) - if (!hasConflicts) { - setEmptyConflictWarning(true) - toast.error(res.message || 'Conflicts reported but list was empty.') - } else { - setEmptyConflictWarning(false) - toast.error(res.message || 'Pull reported conflicts') - } + mutationFn: async (payload?: { resolutions?: GitPullResolution[] }) => { + const sid = sessionId ?? readSessionId() + if (sid) { + const resolutions = payload?.resolutions ?? readResolutions() + const res = await resolvePullSession({ id: sid, requestBody: { resolutions } }) + return { kind: 'resolve' as const, res } + } + const res = await startPullSession() + return { kind: 'start' as const, res } + }, + onSuccess: (data) => { + const res = data.res as GitPullSessionResponse + if ((res as any)?.status === 'stale') { + clearSession() + clearResolutions() + setGlobalConflicts([]) + setEmptyConflictWarning(true) + toast.error('Pull session expired. Please pull again.') + qc.invalidateQueries({ queryKey: ['git-status'] }) return } + const conflicts = res.conflicts ?? [] + const hasConflicts = conflicts.length > 0 + setSessionId(res.session_id) + setSessionIdState(res.session_id) if (hasConflicts) { - updateConflicts(extracted) + clearResolutions() + updateConflicts(conflicts) setEmptyConflictWarning(false) setShowPullDialog(true) return } + // no conflicts + clearSession() updateConflicts([], { allowClear: true }) setEmptyConflictWarning(false) - toast.success(res.message || 'Pull completed') - qc.invalidateQueries({ queryKey: ['git-status'] }) + finalizePullSession({ id: res.session_id }) + .catch(() => { + /* ignore finalize failure; user can retry */ + }) + .finally(() => { + toast.success('Pull completed') + qc.invalidateQueries({ queryKey: ['git-status'] }) + }) }, onError: (e: any) => { const bodyConflicts = extractConflicts((e as any)?.body) + const statusField = (e as any)?.body?.status + const msg = (e as any)?.body?.message || '' + if (statusField === 'stale' || typeof msg === 'string' && msg.toLowerCase().includes('stale')) { + clearSession() + clearResolutions() + updateConflicts([], { allowClear: true }) + setEmptyConflictWarning(true) + toast.error('Pull session expired. Please pull again.') + qc.invalidateQueries({ queryKey: ['git-status'] }) + return + } if (e instanceof ApiError && e.status === 409 && bodyConflicts.length > 0) { + const sid = (e as any)?.body?.session_id || readSessionId() + setSessionId(sid) + setSessionIdState(sid) + clearResolutions() updateConflicts(bodyConflicts) setShowPullDialog(true) return } if (e instanceof ApiError && e.status === 409) { - // Fallback: open dialog and retry fetch inside to display conflicts + clearResolutions() updateConflicts(readConflicts()) setEmptyConflictWarning(true) toast.error('Conflicts reported but server returned no list.') setShowPullDialog(true) return } - const msg = e?.body?.message || e?.message || `${e}` - toast.error(`Pull failed: ${msg}`) + const detail = e?.body?.message || e?.message || `${e}` + toast.error(`Pull failed: ${detail}`) }, }) @@ -214,7 +262,35 @@ function useGitSyncController() { return }, [config, hasChanges, status, statusError, statusLoading, syncPending]) + useEffect(() => { + const sid = sessionId ?? readSessionId() + if (!sid) return + setPolling(true) + const timer = window.setInterval(() => { + getPullSession({ id: sid }) + .then((session) => { + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + setGlobalConflicts([]) + setEmptyConflictWarning(true) + toast.error('Pull session expired. Please pull again.') + return + } + setSessionId(session.session_id) + updateConflicts(session.conflicts ?? [], { allowClear: true }) + }) + .catch(() => {}) + }, 10000) + return () => { + window.clearInterval(timer) + setPolling(false) + } + }, [sessionId, updateConflicts]) + return { + sessionId, + polling, isMobile, syncPending, canSync, @@ -242,6 +318,8 @@ function useGitSyncController() { export default function GitSyncButton({ className, compact = false }: Props) { const controller = useGitSyncController() const { + sessionId, + polling, isMobile, syncPending, canSync, @@ -301,7 +379,9 @@ export default function GitSyncButton({ className, compact = false }: Props) { {icon}

Git Sync

-

{statusText}

+

+ {polling ? 'Synchronizing conflicts…' : statusText} +

@@ -322,6 +402,7 @@ export default function GitSyncButton({ className, compact = false }: Props) { { + clearResolutions() updateConflicts(readConflicts(), { allowClear: true }) setShowPullDialog(true) pullMutation.mutate({ resolutions: [] }) @@ -378,6 +459,7 @@ export default function GitSyncButton({ className, compact = false }: Props) { conflicts={pullConflicts} isLoading={pullMutation.isPending} emptyWarning={emptyConflictWarning} + sessionId={sessionId} onResolve={(resolutions) => pullMutation.mutate({ resolutions })} onRetry={() => pullMutation.mutate({ resolutions: [] })} /> diff --git a/app/src/shared/api/client/sdk.gen.ts b/app/src/shared/api/client/sdk.gen.ts index 2584f6f6..15ba169d 100644 --- a/app/src/shared/api/client/sdk.gen.ts +++ b/app/src/shared/api/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; +import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; /** * @param data The data for the request. @@ -793,6 +793,74 @@ export const pullRepository = (data: PullRepositoryData): CancelablePromise => { + return __request(OpenAPI, { + method: 'GET', + url: '/api/git/pull/session/{id}', + path: { + id: data.id + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id + * @returns GitPullResponse + * @throws ApiError + */ +export const finalizePullSession = (data: FinalizePullSessionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/session/{id}/finalize', + path: { + id: data.id + } + }); +}; + +/** + * @param data The data for the request. + * @param data.id + * @param data.requestBody + * @returns GitPullSessionResponse + * @throws ApiError + */ +export const resolvePullSession = (data: ResolvePullSessionData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/session/{id}/resolve', + path: { + id: data.id + }, + body: data.requestBody, + mediaType: 'application/json', + errors: { + 409: '' + } + }); +}; + +/** + * @returns GitPullSessionResponse + * @throws ApiError + */ +export const startPullSession = (): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/pull/start', + errors: { + 409: 'Conflicts detected' + } + }); +}; + /** * @returns GitStatus * @throws ApiError diff --git a/app/src/shared/api/client/types.gen.ts b/app/src/shared/api/client/types.gen.ts index f21da29b..79ca3c4e 100644 --- a/app/src/shared/api/client/types.gen.ts +++ b/app/src/shared/api/client/types.gen.ts @@ -247,6 +247,7 @@ export type GitHistoryResponse = { export type GitPullConflictItem = { base?: (string) | null; + document_id?: (string) | null; is_binary: boolean; ours?: (string) | null; path: string; @@ -267,10 +268,19 @@ export type GitPullResponse = { commit_hash?: (string) | null; conflicts?: Array | null; files_changed: number; + git_status?: ((GitStatus) | null); message: string; success: boolean; }; +export type GitPullSessionResponse = { + conflicts: Array; + message?: (string) | null; + resolutions: Array; + session_id: string; + status: string; +}; + export type GitRemoteCheckResponse = { message: string; ok: boolean; @@ -1135,6 +1145,27 @@ export type PullRepositoryData = { export type PullRepositoryResponse = (GitPullResponse); +export type GetPullSessionData = { + id: string; +}; + +export type GetPullSessionResponse = (GitPullSessionResponse); + +export type FinalizePullSessionData = { + id: string; +}; + +export type FinalizePullSessionResponse = (GitPullResponse); + +export type ResolvePullSessionData = { + id: string; + requestBody: GitPullRequest; +}; + +export type ResolvePullSessionResponse = (GitPullSessionResponse); + +export type StartPullSessionResponse = (GitPullSessionResponse); + export type GetStatusResponse = (GitStatus); export type SyncNowData = { diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index d5e3fe86..90b97e01 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -1,16 +1,17 @@ import { useMutation, useQueryClient } from '@tanstack/react-query' import { useNavigate } from '@tanstack/react-router' import { BookmarkPlus, Download, History } from 'lucide-react' -import { useCallback, useEffect, useMemo, useState, type ReactNode } from 'react' +import { useCallback, useEffect, useMemo, useRef, useState, type ReactNode } from 'react' import { toast } from 'sonner' import { ApiError, type GitPullConflictItem, type GitPullResolution } from '@/shared/api' import { useRealtime } from '@/shared/contexts/realtime-context' import type { DocumentHeaderAction } from '@/shared/types/document' +import { Button } from '@/shared/ui/button' import { downloadDocumentFile, type DocumentDownloadFormat } from '@/entities/document' +import { pullRepository, getPullSession, resolvePullSession, finalizePullSession } from '@/entities/git' import { createShareMount, shareMountsQuery } from '@/entities/share' -import { pullRepository } from '@/entities/git' import { useAuthContext } from '@/features/auth' import { BacklinksPanel } from '@/features/document-backlinks' @@ -21,8 +22,8 @@ import { } from '@/features/document-download' import { SnapshotHistoryDialog } from '@/features/document-snapshots' import { EditorOverlay, MarkdownEditor, useCollaborativeDocument, useViewContext } from '@/features/edit-document' +import { setConflicts as setGlobalConflicts, readResolutions, setResolutions, clearResolutions, readSessionId, setSessionId, clearSession, readConflicts } from '@/features/git-sync/lib/git-conflict-store' import { usePluginDocumentRedirect } from '@/features/plugins' -import { setConflicts as setGlobalConflicts } from '@/features/git-sync/lib/git-conflict-store' import { useSecondaryViewer } from '@/features/secondary-viewer' type SecondaryViewerType = ReturnType['secondaryDocumentType'] @@ -53,14 +54,155 @@ export type DocumentPageProps = { const normalizeConflictPath = (path?: string | null) => (path || '').replace(/^[./]+/, '').trim().toLowerCase() +type ConflictHunk = { + id: string + ours: string[] + theirs: string[] + oursStart?: number + theirsStart?: number +} + +type ConflictSegments = Array< + | { type: 'equal'; lines: string[] } + | { type: 'conflict'; hunkId: string; ours: string[]; theirs: string[] } +> + +const genHunkId = () => { + if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') return crypto.randomUUID() + return Math.random().toString(36).slice(2) +} + +const buildLineDiffSegments = (oursRaw: string, theirsRaw: string): { segments: ConflictSegments; hunks: ConflictHunk[] } => { + const ours = oursRaw.split('\n') + const theirs = theirsRaw.split('\n') + const m = ours.length + const n = theirs.length + const lcs: number[][] = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0)) + for (let i = m - 1; i >= 0; i -= 1) { + for (let j = n - 1; j >= 0; j -= 1) { + if (ours[i] === theirs[j]) lcs[i][j] = lcs[i + 1][j + 1] + 1 + else lcs[i][j] = Math.max(lcs[i + 1][j], lcs[i][j + 1]) + } + } + + type Op = { type: 'equal' | 'del' | 'ins'; line: string } + const ops: Op[] = [] + let i = 0 + let j = 0 + while (i < m && j < n) { + if (ours[i] === theirs[j]) { + ops.push({ type: 'equal', line: ours[i] }) + i += 1 + j += 1 + } else if (lcs[i + 1][j] >= lcs[i][j + 1]) { + ops.push({ type: 'del', line: ours[i] }) + i += 1 + } else { + ops.push({ type: 'ins', line: theirs[j] }) + j += 1 + } + } + while (i < m) { + ops.push({ type: 'del', line: ours[i] }) + i += 1 + } + while (j < n) { + ops.push({ type: 'ins', line: theirs[j] }) + j += 1 + } + + const segments: ConflictSegments = [] + const hunks: ConflictHunk[] = [] + let currentEqual: string[] = [] + let currentConflict: { ours: string[]; theirs: string[]; hunkId: string } | null = null + + const pushEqual = () => { + if (currentEqual.length) { + segments.push({ type: 'equal', lines: currentEqual }) + currentEqual = [] + } + } + + const pushConflict = () => { + if (currentConflict) { + segments.push({ type: 'conflict', hunkId: currentConflict.hunkId, ours: currentConflict.ours, theirs: currentConflict.theirs }) + hunks.push({ id: currentConflict.hunkId, ours: currentConflict.ours.slice(), theirs: currentConflict.theirs.slice() }) + currentConflict = null + } + } + + for (const op of ops) { + if (op.type === 'equal') { + pushConflict() + currentEqual.push(op.line) + } else { + pushEqual() + if (!currentConflict) { + currentConflict = { ours: [], theirs: [], hunkId: genHunkId() } + } + if (op.type === 'del') currentConflict.ours.push(op.line) + else currentConflict.theirs.push(op.line) + } + } + pushConflict() + pushEqual() + + return { segments, hunks } +} + +const buildMergedText = ( + segments: ConflictSegments, + choices: Record, + defaultPick: 'ours' | 'theirs' = 'ours', +) => { + const out: string[] = [] + for (const seg of segments) { + if (seg.type === 'equal') { + out.push(...seg.lines) + } else { + const pick = choices[seg.hunkId] || defaultPick + out.push(...(pick === 'theirs' ? seg.theirs : seg.ours)) + } + } + return out.join('\n') +} + +const buildHunkAnchors = ( + segments: ConflictSegments, + choices: Record, + defaultPick: 'ours' | 'theirs', +): Array<{ hunkId: string; line: number }> => { + const anchors: Array<{ hunkId: string; line: number }> = [] + let line = 0 + for (const seg of segments) { + if (seg.type === 'equal') { + line += seg.lines.length + } else { + const pick = choices[seg.hunkId] || defaultPick + const lines = pick === 'theirs' ? seg.theirs : seg.ours + const start = line + 1 + const end = line + lines.length + anchors.push({ hunkId: seg.hunkId, line: lines.length ? end : start }) + line = end + } + } + return anchors +} + const matchConflictToDoc = ( conflicts: GitPullConflictItem[], docPaths: Array, + docId: string, ): GitPullConflictItem | null => { + if (conflicts.length === 0) return null const targets = docPaths .map((p) => normalizeConflictPath(p)) .filter((p) => p.length > 0) - if (conflicts.length === 0) return null + + for (const conflict of conflicts) { + if (conflict.document_id && conflict.document_id === docId) return conflict + } + for (const conflict of conflicts) { const candidate = normalizeConflictPath(conflict.path) if (!candidate) continue @@ -99,7 +241,7 @@ export default DocumentPage function DocumentSSRPlaceholder() { return (
- +
) } @@ -121,11 +263,27 @@ function DocumentClient({ const [savingShare, setSavingShare] = useState(false) const [activeConflict, setActiveConflict] = useState(null) const [modifiedText, setModifiedText] = useState('') + const [segments, setSegments] = useState([]) + const [hunks, setHunks] = useState([]) + const [hunkChoices, setHunkChoices] = useState>({}) + const [hunkDefaultSide, setHunkDefaultSide] = useState<'ours' | 'theirs'>('theirs') + const [hunkAnchors, setHunkAnchors] = useState>([]) + const lastPayloadRef = useRef([]) const { secondaryDocumentId, secondaryDocumentType, showSecondaryViewer, closeSecondaryViewer, openSecondaryViewer } = useSecondaryViewer() const { showBacklinks, setShowBacklinks } = useViewContext() const { status, doc, awareness, isReadOnly, error: realtimeError } = useCollaborativeDocument(id, shareToken) const { documentTitle: realtimeTitle, documentActions, setDocumentActions } = useRealtime() const hasDoc = Boolean(doc) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) + useEffect(() => { + const handler = () => setSessionIdState(readSessionId()) + window.addEventListener('storage', handler) + window.addEventListener('refmd:git-conflicts-updated', handler as any) + return () => { + window.removeEventListener('storage', handler) + window.removeEventListener('refmd:git-conflicts-updated', handler as any) + } + }, []) const pluginRedirectEnabled = loaderData?.createdByPlugin === undefined ? true : Boolean(loaderData?.createdByPlugin) const { redirecting, resolving: pluginResolving } = usePluginDocumentRedirect(id, { @@ -159,9 +317,27 @@ function DocumentClient({ (list: GitPullConflictItem[]) => { const safeList = Array.isArray(list) ? list : [] setGlobalConflicts(safeList) - const matched = matchConflictToDoc(safeList, [loaderData?.path, loaderData?.desired_path]) + const matched = matchConflictToDoc(safeList, [loaderData?.path, loaderData?.desired_path], id) setActiveConflict(matched) - setModifiedText(matched?.theirs ?? matched?.ours ?? '') + if (matched && !matched.is_binary) { + const oursText = matched.ours ?? '' + const theirsText = matched.theirs ?? '' + const { segments: segs, hunks: nextHunks } = buildLineDiffSegments(oursText, theirsText) + setSegments(segs) + setHunks(nextHunks) + setHunkChoices({}) + setHunkDefaultSide('theirs') + // Show remote side by default so Monaco diff highlights per-hunk differences. + setModifiedText(theirsText || oursText) + setHunkAnchors(buildHunkAnchors(segs, {}, 'theirs')) + } else { + setSegments([]) + setHunks([]) + setHunkChoices({}) + setHunkDefaultSide('ours') + setModifiedText(matched?.theirs ?? matched?.ours ?? '') + setHunkAnchors([]) + } }, [loaderData?.desired_path, loaderData?.path], ) @@ -170,17 +346,34 @@ function DocumentClient({ if (!conflictMode) return const fetchConflicts = async () => { try { - const res = await pullRepository({ requestBody: { resolutions: [] } }) - setConflictsForDoc(res.conflicts ?? []) - } catch (error) { - if (error instanceof ApiError && error.status === 409) { - const list = ((error.body as any)?.conflicts ?? []) as GitPullConflictItem[] - setConflictsForDoc(list) + if (sessionId) { + const session = await getPullSession({ id: sessionId }) + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + setConflictsForDoc([]) + toast.error('Pull session expired. Please pull again.') + return + } + setSessionId(session.session_id) + setConflictsForDoc(session.conflicts ?? []) + setResolutions(session.resolutions ?? []) + return } + // Fallback: hydrate from local store when session is not yet available. + setConflictsForDoc(readConflicts()) + } catch (error) { + toast.error((error as any)?.body?.message || (error as any)?.message || 'Failed to load conflicts') } } void fetchConflicts() - }, [conflictMode, setConflictsForDoc]) + }, [conflictMode, setConflictsForDoc, sessionId]) + + useEffect(() => { + if (!segments.length) return + setModifiedText(buildMergedText(segments, hunkChoices, hunkDefaultSide)) + setHunkAnchors(buildHunkAnchors(segments, hunkChoices, hunkDefaultSide)) + }, [segments, hunkChoices, hunkDefaultSide]) const openDownloadDialog = useCallback(() => { if (!hasDoc) return @@ -243,50 +436,96 @@ function DocumentClient({ }, replace: true, }) + setConflictsForDoc([]) + clearResolutions() + clearSession() + lastPayloadRef.current = [] qc.invalidateQueries({ queryKey: ['git-status'] }) }, [id, navigate, qc]) const pullMutation = useMutation({ - mutationFn: async (resolution: GitPullResolution) => - pullRepository({ requestBody: { resolutions: [resolution] } }), + mutationFn: async (resolutions: GitPullResolution[]) => { + if (sessionId) { + return resolvePullSession({ id: sessionId, requestBody: { resolutions } }) + } + return pullRepository({ requestBody: { resolutions } }) + }, onSuccess: (res) => { + if ((res as any)?.status === 'stale') { + clearSession() + clearResolutions() + setConflictsForDoc([]) + toast.error('Pull session expired. Please pull again.') + return + } const remaining = res.conflicts ?? [] setConflictsForDoc(remaining) - const stillPending = matchConflictToDoc(remaining, [loaderData?.path, loaderData?.desired_path]) + const stillPending = matchConflictToDoc(remaining, [loaderData?.path, loaderData?.desired_path], id) + if (remaining.length === 0) { + clearResolutions() + lastPayloadRef.current = [] + if (sessionId) { + void finalizePullSession({ id: sessionId }).finally(() => clearSession()) + } + } else { + const payload = lastPayloadRef.current || [] + setResolutions(payload) + } + const message = 'message' in res && typeof res.message === 'string' ? res.message : undefined if (stillPending) { - toast.success(res.message || 'Resolution applied. Another conflict remains for this document.') + toast.success(message || 'Resolution applied. Another conflict remains for this document.') } else { - toast.success(res.message || 'Conflict resolved') - handleConflictResolved() + toast.success(message || 'Conflict resolved') + if (remaining.length === 0) { + handleConflictResolved() + } } }, onError: (err) => { + const statusField = (err as any)?.body?.status + const msg = (err as any)?.body?.message || '' + if (statusField === 'stale' || (typeof msg === 'string' && msg.toLowerCase().includes('stale'))) { + clearSession() + clearResolutions() + setConflictsForDoc([]) + toast.error('Pull session expired. Please pull again.') + return + } if (err instanceof ApiError && err.status === 409) { const next = ((err.body as any)?.conflicts ?? []) as GitPullConflictItem[] setConflictsForDoc(next) toast.error((err.body as any)?.message || 'Conflicts remain. Please resolve and try again.') return } - const msg = (err as any)?.body?.message || (err as any)?.message || 'Failed to apply resolution' - toast.error(msg) + const detail = (err as any)?.body?.message || (err as any)?.message || 'Failed to apply resolution' + toast.error(detail) }, }) - const handleApplyResolution = useCallback( - (choice: GitPullResolution['choice']) => { - if (!activeConflict) return - if (choice === 'custom_text' && !modifiedText.trim()) { - toast.error('Add your merged content before applying.') - return - } - const resolution: GitPullResolution = { - path: activeConflict.path, - choice, - content: choice === 'custom_text' ? modifiedText : undefined, + const submitResolution = useCallback( + (resolution: GitPullResolution) => { + const preserved = readResolutions().filter((r) => r.path !== resolution.path) + const payload = [...preserved, resolution] + setResolutions(payload) + lastPayloadRef.current = payload + if (sessionId) { + pullMutation.mutate(payload) + } else { + pullRepository({ requestBody: { resolutions: payload } }) + .then((res) => { + const remaining = res.conflicts ?? [] + setConflictsForDoc(remaining) + if (remaining.length === 0) { + clearResolutions() + handleConflictResolved() + } + }) + .catch((err) => { + toast.error((err as any)?.body?.message || (err as any)?.message || 'Failed to apply resolution') + }) } - pullMutation.mutate(resolution) }, - [activeConflict, modifiedText, pullMutation], + [pullMutation, sessionId, handleConflictResolved, setConflictsForDoc], ) useEffect(() => { @@ -357,12 +596,12 @@ function DocumentClient({ const overlayLabel = realtimeError ? realtimeError : pluginResolving - ? 'Preparing plugin…' + ? 'Preparing plugin...' : redirecting - ? 'Opening plugin…' + ? 'Opening plugin...' : status === 'connecting' - ? 'Connecting…' - : 'Loading…' + ? 'Connecting...' + : 'Loading...' const showEditor = Boolean(doc && awareness && !realtimeError) const showOverlay = shouldShowOverlay @@ -373,11 +612,11 @@ function DocumentClient({ const computedTitle = (() => { if (!baseTitle) return 'RefMD' if (shareToken) return baseTitle - return `${baseTitle} • RefMD` + return `${baseTitle} - RefMD` })() document.title = computedTitle - const summary = (() => { + const summary = (() => { if (!baseTitle) return shareToken ? 'Shared document on RefMD' : 'Editing a document on RefMD' if (shareToken) return baseTitle return `${baseTitle} on RefMD` @@ -423,47 +662,104 @@ function DocumentClient({ const renderSecondaryViewer = secondaryViewerRenderer const oursText = activeConflict?.ours ?? '' - const theirsText = activeConflict?.theirs ?? '' const isBinaryConflict = activeConflict?.is_binary ?? false - const showConflictUI = Boolean(conflictMode) - - const conflictView = showConflictUI - ? activeConflict - ? { - kind: isBinaryConflict ? 'binary' as const : 'text' as const, - original: oursText, - modified: modifiedText, - onChange: setModifiedText, - readOnly: pullMutation.isPending, - actions: { - onKeepMine: () => { - setModifiedText(oursText) - handleApplyResolution('ours') - }, - onTakeTheirs: () => { - setModifiedText(theirsText) - handleApplyResolution('theirs') - }, - onApplyMerged: !isBinaryConflict - ? () => { - handleApplyResolution('custom_text') - } - : undefined, - }, - } - : { - kind: 'binary' as const, - original: '', - modified: '', - onChange: () => {}, - readOnly: true, - actions: undefined, - } + const hunkCount = useMemo(() => hunks.length, [hunks]) + const resolvedHunks = useMemo(() => hunks.filter((h) => hunkChoices[h.id]).length, [hunks, hunkChoices]) + const allResolved = useMemo(() => (hunkCount ? resolvedHunks === hunkCount : true), [hunkCount, resolvedHunks]) + + const chooseHunkSide = useCallback((hunkId: string, side: 'ours' | 'theirs') => { + setHunkChoices((prev) => ({ ...prev, [hunkId]: side })) + }, []) + + const setAllHunks = useCallback( + (side: 'ours' | 'theirs') => { + if (!hunks.length) return + const entries = Object.fromEntries(hunks.map((h) => [h.id, side])) + setHunkChoices(entries) + setHunkDefaultSide(side) + }, + [hunks], + ) + + const handleApplyResolution = useCallback( + (choice: GitPullResolution['choice'], customContent?: string) => { + if (!activeConflict) return + if (choice === 'custom_text' && !allResolved) { + toast.error('Resolve all hunks before applying.') + return + } + if (choice === 'custom_text' && !(customContent ?? modifiedText).trim()) { + toast.error('Add your merged content before applying.') + return + } + const resolution: GitPullResolution = { + path: activeConflict.path, + choice, + content: choice === 'custom_text' ? customContent ?? modifiedText : undefined, + } + submitResolution(resolution) + }, + [activeConflict, allResolved, modifiedText, submitResolution], + ) + + const showConflictUI = Boolean(activeConflict) + + const conflictView = showConflictUI && activeConflict + ? { + kind: isBinaryConflict ? 'binary' as const : 'text' as const, + original: oursText, + modified: modifiedText, + onChange: setModifiedText, + readOnly: pullMutation.isPending, + actions: !isBinaryConflict + ? { + onKeepMine: () => { + setAllHunks('ours') + setModifiedText(oursText) + }, + onTakeTheirs: () => { + setAllHunks('theirs') + setModifiedText(activeConflict?.theirs ?? '') + }, + onApplyMerged: () => { + handleApplyResolution('custom_text', modifiedText) + }, + } + : undefined, + } : undefined + const conflictHunkWidgets = + showConflictUI && activeConflict && !isBinaryConflict && hunks.length + ? hunkAnchors.map((anchor) => ({ + id: anchor.hunkId, + line: anchor.line, + choice: hunkChoices[anchor.hunkId], + onChoose: (side: 'ours' | 'theirs') => chooseHunkSide(anchor.hunkId, side), + })) + : undefined + return (
+ {showConflictUI && activeConflict ? ( +
+ + + {isBinaryConflict ? 'Binary conflict' : `${hunkCount} hunks (${resolvedHunks} decided)`} + + {!isBinaryConflict ? ( + + ) : null} +
+ ) : null} {showOverlay && } {showEditor ? ( setShowBacklinks(false)} /> diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index 65b2876f..dc7a1f73 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -18,6 +18,7 @@ import { SidebarHeader, SidebarContent, SidebarFooter, SidebarGroup, SidebarGrou import { Tooltip, TooltipContent, TooltipTrigger } from '@/shared/ui/tooltip' import { downloadWorkspaceArchive } from '@/entities/document' +import { getPullSession } from '@/entities/git' import { useAuthContext } from '@/features/auth' import { useEditorContext } from '@/features/edit-document' @@ -31,7 +32,7 @@ import { useFileTreeDrag } from '@/features/file-tree/lib/useFileTreeDrag' import FileNode from '@/features/file-tree/ui/FileNode' import FolderNode from '@/features/file-tree/ui/FolderNode' import { GitSyncButton } from '@/features/git-sync' -import { GIT_CONFLICT_EVENT, readConflicts } from '@/features/git-sync/lib/git-conflict-store' +import { GIT_CONFLICT_EVENT, readConflicts, readSessionId, setConflicts as setGlobalConflicts, setSessionId, clearSession, clearResolutions } from '@/features/git-sync/lib/git-conflict-store' import { useSecondaryViewer } from '@/features/secondary-viewer' import { ShareDialog } from '@/features/sharing' import { @@ -267,6 +268,7 @@ function FileTreeInner() { const [shareFolderId, setShareFolderId] = useState(null) const [workspaceDownloadPending, setWorkspaceDownloadPending] = useState(false) const [gitConflicts, setGitConflicts] = useState(() => readConflicts()) + const [sessionId, setSessionIdState] = useState(() => readSessionId()) const openTemporaryDocument = useCallback(() => { if (typeof window === 'undefined') return const entry = createTemporaryDocumentEntry() @@ -278,7 +280,7 @@ function FileTreeInner() { const refreshTempEntries = useCallback(() => { if (typeof window === 'undefined') return [] as TemporaryDocumentMeta[] return listTemporaryDocuments() - }, []) + }, [sessionId]) const clearAllTemporaries = useCallback(() => { const list = refreshTempEntries() list.forEach((entry) => deleteTemporaryDocumentEntry(entry.id)) @@ -343,12 +345,56 @@ function FileTreeInner() { }, []) useEffect(() => { - const handler = () => setGitConflicts(readConflicts()) - window.addEventListener(GIT_CONFLICT_EVENT, handler) + const handler = (event: Event) => { + const detail = (event as CustomEvent)?.detail + if (Array.isArray(detail)) { + setGitConflicts(detail as GitPullConflictItem[]) + } else { + setGitConflicts(readConflicts()) + } + } + const sessionHandler = () => setSessionIdState(readSessionId()) + window.addEventListener(GIT_CONFLICT_EVENT, handler as EventListener) window.addEventListener('storage', handler) + window.addEventListener('storage', sessionHandler) return () => { - window.removeEventListener(GIT_CONFLICT_EVENT, handler) + window.removeEventListener(GIT_CONFLICT_EVENT, handler as EventListener) window.removeEventListener('storage', handler) + window.removeEventListener('storage', sessionHandler) + } + }, []) + + useEffect(() => { + const sid = sessionId ?? readSessionId() + if (!sid) return + let cancelled = false + const syncSession = () => { + getPullSession({ id: sid }) + .then((session) => { + if (cancelled) return + if ((session as any)?.status === 'stale') { + clearSession() + clearResolutions() + setGitConflicts([]) + return + } + if ((session as any)?.status === 'merged' && (session.conflicts ?? []).length === 0) { + clearSession() + clearResolutions() + setGitConflicts([]) + return + } + setSessionId(session.session_id) + setGlobalConflicts(session.conflicts ?? []) + setGitConflicts(session.conflicts ?? []) + }) + .catch(() => {}) + } + syncSession() + const timer = window.setInterval(syncSession, 10000) + return () => { + cancelled = true + window.clearInterval(timer) } }, []) @@ -449,11 +495,25 @@ function FileTreeInner() { (node: DocumentNode): GitPullConflictItem | null => { if (node.type !== 'file') return null const targets = [normalizeConflictPath(node.path), normalizeConflictPath(node.desiredPath)].filter(Boolean) - if (!targets.length) return null + const names = new Set() + const addName = (value?: string | null) => { + if (!value) return + const trimmed = value.trim().toLowerCase() + if (trimmed) names.add(trimmed) + } + addName(node.title) + targets.forEach((t) => { + addName(t.split('/').pop()) + }) + if (!targets.length && names.size === 0) return null for (const conflict of gitConflicts) { + if (conflict.document_id && conflict.document_id === node.id) { + return conflict + } const candidate = normalizeConflictPath(conflict.path) if (!candidate) continue - if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`))) { + const candidateBase = candidate.split('/').pop() + if (targets.some((t) => candidate === t || candidate.endsWith(`/${t}`)) || (candidateBase && names.has(candidateBase))) { return conflict } } From d11d3776af8b6388e815e13c503d52eae95eaf39 Mon Sep 17 00:00:00 2001 From: munenick Date: Wed, 10 Dec 2025 15:53:58 +0900 Subject: [PATCH 04/16] refactor: pull --- api/src/application/ports/git_workspace.rs | 2 +- api/src/application/ports/mod.rs | 2 +- api/src/application/services/git.rs | 49 ++- api/src/application/services/git_rebuild.rs | 2 +- api/src/bin/refmd.rs | 2 +- .../git_pull_session_repository_sqlx.rs | 8 +- .../documents/git_dirty_subscriber.rs | 11 + api/src/infrastructure/git/workspace.rs | 308 ++++++++++++++---- api/src/main.rs | 1 + api/src/presentation/http/git.rs | 69 ++-- .../edit-document/ui/EditorLayout.tsx | 61 ++-- app/src/widgets/document/DocumentPage.tsx | 2 +- 12 files changed, 386 insertions(+), 131 deletions(-) diff --git a/api/src/application/ports/git_workspace.rs b/api/src/application/ports/git_workspace.rs index d2f50178..3b8e8705 100644 --- a/api/src/application/ports/git_workspace.rs +++ b/api/src/application/ports/git_workspace.rs @@ -56,7 +56,7 @@ pub trait GitWorkspacePort: Send + Sync { &self, workspace_id: Uuid, repo: &git2::Repository, - remote_oid: git2::Oid, + base_oid: git2::Oid, ) -> anyhow::Result; async fn check_remote( diff --git a/api/src/application/ports/mod.rs b/api/src/application/ports/mod.rs index 4134b849..5dbff5b8 100644 --- a/api/src/application/ports/mod.rs +++ b/api/src/application/ports/mod.rs @@ -6,11 +6,11 @@ pub mod document_exporter; pub mod document_repository; pub mod document_snapshot_archive_repository; pub mod files_repository; +pub mod git_pull_session_repository; pub mod git_rebuild_job_queue; pub mod git_repository; pub mod git_storage; pub mod git_workspace; -pub mod git_pull_session_repository; pub mod gitignore_port; pub mod health_probe; pub mod linkgraph_repository; diff --git a/api/src/application/services/git.rs b/api/src/application/services/git.rs index d8eb2b15..41bf1339 100644 --- a/api/src/application/services/git.rs +++ b/api/src/application/services/git.rs @@ -4,9 +4,9 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitConfigDto, GitPullConflictItemDto, GitRemoteCheckDto, - GitStatusDto, GitSyncRequestDto, GitSyncResponseDto, GitignoreUpdateDto, GitPullRequestDto, - GitPullResultDto, GitPullSessionDto, UpsertGitConfigInput, + GitChangeItem, GitCommitInfo, GitConfigDto, GitPullConflictItemDto, GitPullRequestDto, + GitPullResultDto, GitPullSessionDto, GitRemoteCheckDto, GitStatusDto, GitSyncRequestDto, + GitSyncResponseDto, GitignoreUpdateDto, UpsertGitConfigInput, }; use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::files_repository::FilesRepository; @@ -345,7 +345,10 @@ impl GitService { })?; if let Some(conflicts) = dto.conflicts.take() { - dto.conflicts = Some(self.attach_conflict_documents(workspace_id, conflicts).await?); + dto.conflicts = Some( + self.attach_conflict_documents(workspace_id, conflicts) + .await?, + ); } Ok(dto) @@ -357,9 +360,17 @@ impl GitService { conflicts: Vec, ) -> Result, ServiceError> { let mut out = Vec::with_capacity(conflicts.len()); - let docs = self.docs.list_workspace_documents(workspace_id).await.map_err(ServiceError::from)?; + let docs = self + .docs + .list_workspace_documents(workspace_id) + .await + .map_err(ServiceError::from)?; - let normalize = |path: &str| path.trim_start_matches("./").trim_start_matches('/').to_string(); + let normalize = |path: &str| { + path.trim_start_matches("./") + .trim_start_matches('/') + .to_string() + }; for mut conflict in conflicts { if conflict.document_id.is_some() { @@ -382,7 +393,11 @@ impl GitService { paths.push(desired); } - if paths.iter().any(|p| candidate == *p || candidate.ends_with(&format!("/{p}")) || p.ends_with(&candidate)) { + if paths.iter().any(|p| { + candidate == *p + || candidate.ends_with(&format!("/{p}")) + || p.ends_with(&candidate) + }) { matched = Some(doc.id); break; } @@ -404,13 +419,20 @@ impl GitService { &self, workspace_id: Uuid, ) -> Result { - self - .pull_repository(workspace_id, GitPullRequestDto { resolutions: Vec::new() }) - .await + self.pull_repository( + workspace_id, + GitPullRequestDto { + resolutions: Vec::new(), + }, + ) + .await } pub async fn save_pull_session(&self, session: GitPullSessionDto) -> Result<(), ServiceError> { - self.pull_sessions.upsert(session).await.map_err(ServiceError::from) + self.pull_sessions + .upsert(session) + .await + .map_err(ServiceError::from) } pub async fn load_pull_session( @@ -418,7 +440,10 @@ impl GitService { workspace_id: Uuid, id: Uuid, ) -> Result, ServiceError> { - self.pull_sessions.get(workspace_id, id).await.map_err(ServiceError::from) + self.pull_sessions + .get(workspace_id, id) + .await + .map_err(ServiceError::from) } pub async fn pull_session_is_stale( diff --git a/api/src/application/services/git_rebuild.rs b/api/src/application/services/git_rebuild.rs index 537bf15c..c915b3c1 100644 --- a/api/src/application/services/git_rebuild.rs +++ b/api/src/application/services/git_rebuild.rs @@ -381,7 +381,7 @@ mod tests { &self, _workspace_id: Uuid, _repo: &git2::Repository, - _remote_oid: git2::Oid, + _base_oid: git2::Oid, ) -> anyhow::Result { anyhow::bail!("not supported") } diff --git a/api/src/bin/refmd.rs b/api/src/bin/refmd.rs index 37dace60..a83a6ece 100644 --- a/api/src/bin/refmd.rs +++ b/api/src/bin/refmd.rs @@ -644,7 +644,7 @@ impl GitWorkspacePort for CliGitWorkspace { &self, _workspace_id: Uuid, _repo: &git2::Repository, - _remote_oid: git2::Oid, + _base_oid: git2::Oid, ) -> anyhow::Result { anyhow::bail!("not supported in CLI") } diff --git a/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs index 36d39f72..1982dcdc 100644 --- a/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs +++ b/api/src/infrastructure/db/repositories/git_pull_session_repository_sqlx.rs @@ -3,7 +3,9 @@ use sqlx::types::Json; use sqlx::{PgPool, Row}; use uuid::Uuid; -use crate::application::dto::git::{GitPullConflictItemDto, GitPullResolutionDto, GitPullSessionDto}; +use crate::application::dto::git::{ + GitPullConflictItemDto, GitPullResolutionDto, GitPullSessionDto, +}; use crate::application::ports::git_pull_session_repository::GitPullSessionRepository; pub struct GitPullSessionRepositorySqlx { @@ -54,7 +56,9 @@ impl GitPullSessionRepository for GitPullSessionRepositorySqlx { .fetch_optional(&self.pool) .await?; - let Some(row) = row else { return Ok(None); }; + let Some(row) = row else { + return Ok(None); + }; let conflicts: Vec = row .get::>, _>("conflicts") .0; diff --git a/api/src/infrastructure/documents/git_dirty_subscriber.rs b/api/src/infrastructure/documents/git_dirty_subscriber.rs index 6eb218f5..cad51b43 100644 --- a/api/src/infrastructure/documents/git_dirty_subscriber.rs +++ b/api/src/infrastructure/documents/git_dirty_subscriber.rs @@ -27,6 +27,17 @@ impl GitDirtyDocEventSubscriber { .map(|row| row.flatten()) } + async fn desired_path(&self, doc_id: Uuid) -> anyhow::Result> { + sqlx::query_scalar::<_, Option>( + "SELECT desired_path FROM documents WHERE id = $1", + ) + .bind(doc_id) + .fetch_optional(&self.pool) + .await + .map_err(anyhow::Error::from) + .map(|row| row.flatten()) + } + async fn doc_type(&self, doc_id: Uuid) -> anyhow::Result> { sqlx::query_scalar::<_, Option>("SELECT type FROM documents WHERE id = $1") .bind(doc_id) diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index f2f4ede8..1c501c04 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -1,7 +1,7 @@ use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet}; use std::fs; use std::io::{self, ErrorKind, Write}; -use std::path::{Path, PathBuf}; +use std::path::PathBuf; use std::sync::Arc; use anyhow::{Context, anyhow}; @@ -27,6 +27,7 @@ use crate::application::ports::git_storage::{ BlobKey, CommitMeta, GitStorage, decode_commit_id, encode_commit_id, }; use crate::application::ports::git_workspace::GitWorkspacePort; +use crate::application::ports::realtime_port::RealtimeEngine; use crate::application::ports::storage_port::StorageResolverPort; use crate::application::services::diff::text_diff::compute_text_diff; use crate::application::services::realtime::snapshot::SnapshotService; @@ -38,6 +39,7 @@ pub struct GitWorkspaceService { git_storage: Arc, storage: Arc, snapshot: Arc, + realtime: Arc, } impl GitWorkspaceService { @@ -46,12 +48,14 @@ impl GitWorkspaceService { git_storage: Arc, storage: Arc, snapshot: Arc, + realtime: Arc, ) -> anyhow::Result { Ok(Self { pool, git_storage, storage, snapshot, + realtime, }) } @@ -981,6 +985,59 @@ impl GitWorkspaceService { Ok(changed) } + /// Apply merged markdown files directly to realtime/persistence so documents reflect Pull results. + async fn apply_merged_to_documents( + &self, + workspace_id: Uuid, + next_state: &HashMap, + ) -> anyhow::Result<()> { + let doc_rows = sqlx::query( + "SELECT id, desired_path FROM documents WHERE owner_id = $1 AND type <> 'folder'", + ) + .bind(workspace_id) + .fetch_all(&self.pool) + .await?; + + for row in doc_rows { + let doc_id: Uuid = row.get("id"); + let repo_path: Option = row.try_get("desired_path").ok(); + let Some(repo_path) = repo_path else { continue }; + let normalized = normalize_repo_path(repo_path); + let Some(snapshot) = next_state.get(&normalized) else { + continue; + }; + + if !snapshot.is_text { + continue; + } + let bytes = match self.snapshot_bytes(snapshot).await { + Ok(b) => b, + Err(err) => { + warn!(document_id = %doc_id, error = ?err, "git_pull_snapshot_bytes_failed"); + continue; + } + }; + let body = match extract_markdown_body(&bytes) { + Some(b) => b, + None => continue, + }; + let snap_bytes = + crate::application::services::realtime::snapshot::snapshot_from_markdown(&body); + if let Err(err) = self + .realtime + .apply_snapshot(&doc_id.to_string(), snap_bytes.as_slice()) + .await + { + warn!(document_id = %doc_id, error = ?err, "git_pull_apply_snapshot_failed"); + continue; + } + if let Err(err) = self.realtime.force_persist(&doc_id.to_string()).await { + warn!(document_id = %doc_id, error = ?err, "git_pull_force_persist_failed"); + } + } + Ok(()) + } + fn build_diff_result( &self, path: &str, @@ -1999,9 +2056,7 @@ impl GitWorkspacePort for GitWorkspaceService { if let Some((_, pack_paths)) = self .persist_pack_chain( workspace_id, - latest_meta - .as_ref() - .map(|m| m.commit_id.as_slice()), + latest_meta.as_ref().map(|m| m.commit_id.as_slice()), ) .await? { @@ -2027,8 +2082,15 @@ impl GitWorkspacePort for GitWorkspaceService { let local_oid = latest_meta .as_ref() .and_then(|m| git2::Oid::from_bytes(&m.commit_id).ok()); - // Detect drift between latest commit and current workspace (dirty rows or actual state diff) + // Detect drift between latest commit and current workspace using the same dirty set as Git Changes/Status. let dirty_rows = self.fetch_dirty(workspace_id).await?; + // Track paths that are dirty or differ from base to scope conflicts to overlapping files. + let mut dirty_paths: HashSet = HashSet::new(); + for d in dirty_rows.iter() { + if let Ok(rel) = repo_relative_path(&d.path) { + dirty_paths.insert(rel); + } + } let mut drift_detected = !dirty_rows.is_empty(); let current_state = self.collect_current_state(workspace_id).await?; let mut current_index: HashMap = HashMap::new(); @@ -2036,6 +2098,7 @@ impl GitWorkspacePort for GitWorkspaceService { current_index.insert(path.clone(), snapshot.hash.clone()); if base_index.get(path) != Some(&snapshot.hash) { drift_detected = true; + dirty_paths.insert(path.clone()); } } @@ -2044,6 +2107,7 @@ impl GitWorkspacePort for GitWorkspaceService { for path in base_index.keys() { if !current_index.contains_key(path) { drift_detected = true; + dirty_paths.insert(path.clone()); break; } } @@ -2146,37 +2210,96 @@ impl GitWorkspacePort for GitWorkspaceService { document_id: None, }); } - // If commit IDs differ but no file-level diff was detected (should be rare), - // still treat as remote changes to avoid silent application. + + // If commits differ but no conflict paths were detected above, fallback to diff of current vs remote trees. if remote_conflicts.is_empty() { - if let Some(local_oid_val) = local_oid { - if remote_oid != local_oid_val { + let local_oid_val = local_oid.unwrap_or(remote_oid); + if remote_oid != local_oid_val { + let mut all_paths: HashSet = HashSet::new(); + for p in remote_state.keys() { + all_paths.insert(p.clone()); + } + for p in current_state.keys() { + all_paths.insert(p.clone()); + } + for path in all_paths { + let remote_hash = remote_state.get(&path).map(|s| &s.hash); + let local_hash = current_state.get(&path).map(|s| &s.hash); + if remote_hash == local_hash { + continue; + } + + let ours_bytes = if let Some(snap) = current_state.get(&path) { + Some(self.snapshot_bytes(snap).await?) + } else { + None + }; + let theirs_bytes = if let Some(snap) = remote_state.get(&path) { + Some(self.snapshot_bytes(snap).await?) + } else { + Some(Vec::new()) + }; + let base_bytes = if let Some(meta) = latest_meta.as_ref() { + self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), &path) + .await? + } else { + None + }; + + let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + remote_conflicts.push(GitPullConflictItemDto { - path: "".to_string(), - is_binary: false, - ours: None, - theirs: None, - base: None, + path: path.clone(), + is_binary, + ours, + theirs, + base, document_id: None, }); } - } else { - // No local commit but remote exists. - remote_conflicts.push(GitPullConflictItemDto { - path: "".to_string(), + } + } + let remote_changes = !remote_conflicts.is_empty(); + + info!( + workspace_id = %workspace_id, + dirty_count = dirty_rows.len(), + remote_conflict_count = remote_conflicts.len(), + remote_changes = remote_changes, + resolutions_count = req.resolutions.len(), + "git_pull_debug_state" + ); + + // If workspace has dirty changes and overlapping remote changes, require explicit resolutions. + if remote_changes && !dirty_rows.is_empty() && req.resolutions.is_empty() { + let conflicts = if remote_conflicts.is_empty() { + vec![GitPullConflictItemDto { + path: "".to_string(), is_binary: false, ours: None, theirs: None, base: None, document_id: None, - }); - } + }] + } else { + remote_conflicts.clone() + }; + return Ok(GitPullResultDto { + success: false, + message: "conflicts detected".to_string(), + files_changed: 0, + commit_hash: None, + conflicts: Some(conflicts), + base_commit: base_commit.clone(), + remote_commit: remote_commit.clone(), + }); } - let remote_changes = !remote_conflicts.is_empty(); - let requires_resolution = remote_changes && drift_detected; - // If remote has changes conflicting with local drift and no resolutions are provided, ask client to resolve. - if requires_resolution && req.resolutions.is_empty() { + // When remote changes exist and no resolutions are provided, require resolution. + if remote_changes && req.resolutions.is_empty() { return Ok(GitPullResultDto { success: false, message: "conflicts detected".to_string(), @@ -2188,35 +2311,15 @@ impl GitWorkspacePort for GitWorkspaceService { }); } - // Allow pull even when dirty changes exist; the current workspace state is treated as "ours". - // Validation for concurrent edits is handled later by conflict resolution. - // If remote contains local, treat as fast-forward. - // If remote contains local and remote has changes, return conflicts (no auto-apply). - if let Some(local_oid_val) = local_oid { - if repo.graph_descendant_of(remote_oid, local_oid_val)? - && requires_resolution - && req.resolutions.is_empty() - { - return Ok(GitPullResultDto { - success: false, - message: "conflicts detected".to_string(), - files_changed: 0, - commit_hash: None, - conflicts: Some(remote_conflicts), - base_commit: base_commit.clone(), - remote_commit: remote_commit.clone(), - }); - } - } - // Diverged: merge local into remote (linear, parent = remote) - let Some(_local_oid_val) = local_oid else { + let Some(local_oid_val) = local_oid else { anyhow::bail!("no local commit to merge"); }; let (meta, pack_bytes, merged_snapshots, commit_hex) = { - // Build a synthetic "ours" commit from the current workspace state so dirty edits are preserved. - let synthetic_ours = self.build_synthetic_commit(workspace_id, &repo, remote_oid)?; + // Build a synthetic "ours" commit from the current workspace state anchored to the local head + // so dirty edits participate in the merge against remote changes. + let synthetic_ours = self.build_synthetic_commit(workspace_id, &repo, local_oid_val)?; let ours_commit = repo.find_commit(synthetic_ours)?; let remote_commit_obj = repo.find_commit(remote_oid)?; let index = repo.merge_commits(&ours_commit, &remote_commit_obj, None)?; @@ -2235,8 +2338,12 @@ impl GitWorkspacePort for GitWorkspaceService { } // Collect conflict entries for resolution application - let mut conflict_entries: Vec<(String, Option>, Option>, Option>)> = - Vec::new(); + let mut conflict_entries: Vec<( + String, + Option>, + Option>, + Option>, + )> = Vec::new(); { let mut conflicts_iter = index.conflicts()?; while let Some(conflict) = conflicts_iter.next() { @@ -2272,7 +2379,11 @@ impl GitWorkspacePort for GitWorkspaceService { let resolution_map: std::collections::HashMap< String, &crate::application::dto::git::GitPullResolutionDto, - > = req.resolutions.iter().map(|r| (r.path.clone(), r)).collect(); + > = req + .resolutions + .iter() + .map(|r| (r.path.clone(), r)) + .collect(); // Build merged state from resolved index (stage 0) plus user resolutions. let mut merged_snapshots: HashMap = HashMap::new(); @@ -2300,9 +2411,12 @@ impl GitWorkspacePort for GitWorkspaceService { for (path, ours_bytes, theirs_bytes, base_bytes) in conflict_entries { let resolution = resolution_map.get(&path); if resolution.is_none() { - let (ours_txt, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); - let (theirs_txt, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); - let (base_txt, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let (ours_txt, ours_bin) = + as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (theirs_txt, theirs_bin) = + as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (base_txt, base_bin) = + as_text_or_binary(path.as_str(), base_bytes.as_ref()); unresolved.push(GitPullConflictItemDto { path: path.clone(), is_binary: ours_bin || theirs_bin || base_bin, @@ -2318,9 +2432,7 @@ impl GitWorkspacePort for GitWorkspaceService { let selected_bytes = match res.choice.as_str() { "ours" => ours_bytes.clone(), "theirs" => theirs_bytes.clone(), - "custom_text" => { - Some(res.content.clone().unwrap_or_default().into_bytes()) - } + "custom_text" => Some(res.content.clone().unwrap_or_default().into_bytes()), other => anyhow::bail!("unsupported resolution choice {other}"), } .unwrap_or_default(); @@ -2463,6 +2575,10 @@ impl GitWorkspacePort for GitWorkspaceService { .apply_state_to_workspace(workspace_id, &merged_snapshots, &previous_index) .await?; + // Apply merged markdown back into realtime/doc storage immediately. + self.apply_merged_to_documents(workspace_id, &merged_snapshots) + .await?; + self.clear_dirty(workspace_id).await.ok(); Ok(GitPullResultDto { @@ -2522,31 +2638,54 @@ impl GitWorkspacePort for GitWorkspaceService { &self, workspace_id: Uuid, repo: &Repository, - remote_oid: git2::Oid, + base_oid: git2::Oid, ) -> anyhow::Result { - // Collect current workspace state into blobs and a tree. - let handle = tokio::runtime::Handle::current(); - let current_state = handle.block_on(self.collect_current_state(workspace_id))?; + // Collect current workspace state into blobs and index entries (supports nested paths). + let current_state = tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(self.collect_current_state(workspace_id)) + })?; + + let mut index = repo.index()?; + index.clear()?; - let mut tree_builder = repo.treebuilder(None)?; for (path, snapshot) in current_state.iter() { - let bytes = handle.block_on(self.snapshot_bytes(snapshot))?; + let bytes = tokio::task::block_in_place(|| { + let handle = tokio::runtime::Handle::current(); + handle.block_on(self.snapshot_bytes(snapshot)) + })?; let blob_oid = repo.blob(&bytes)?; - let mode = 0o100644; - tree_builder.insert(Path::new(path), blob_oid, mode)?; + + let entry = git2::IndexEntry { + ctime: git2::IndexTime::new(0, 0), + mtime: git2::IndexTime::new(0, 0), + dev: 0, + ino: 0, + mode: 0o100644, + uid: 0, + gid: 0, + file_size: bytes.len() as u32, + id: blob_oid, + flags: std::cmp::min(path.as_bytes().len(), 0x0fff) as u16, + flags_extended: 0, + path: path.as_bytes().to_vec(), + }; + index.add(&entry)?; } - let tree_oid = tree_builder.write()?; + + let tree_oid = index.write_tree_to(repo)?; let tree = repo.find_tree(tree_oid)?; // Create a synthetic commit with remote as parent to anchor the merge base. - let sig = repo.signature()?; + // Use an explicit signature so we don't rely on local git config being present. + let sig = signature_from_parts("RefMD", "refmd@example.com", Utc::now())?; let commit_oid = repo.commit( Some("refs/heads/synthetic-workspace"), &sig, &sig, "workspace-state", &tree, - &[&repo.find_commit(remote_oid)?], + &[&repo.find_commit(base_oid)?], )?; Ok(commit_oid) } @@ -2849,6 +2988,24 @@ fn apply_pack_to_repo(repo: &Repository, pack: &[u8]) -> anyhow::Result<()> { Ok(()) } +fn extract_markdown_body(bytes: &[u8]) -> Option { + let text = std::str::from_utf8(bytes).ok()?; + let trimmed = text.trim_start_matches('\u{feff}'); + if let Some(rest) = trimmed + .strip_prefix("---\n") + .or_else(|| trimmed.strip_prefix("---\r\n")) + { + let delimiters = ["\n---\n", "\r\n---\r\n", "\n---\r\n", "\r\n---\n"]; + for delim in delimiters { + if let Some(pos) = rest.find(delim) { + let body = &rest[pos + delim.len()..]; + return Some(body.to_string()); + } + } + } + Some(trimmed.to_string()) +} + fn missing_metadata_commit(err: &anyhow::Error) -> Option { let needle = "metadata not found for commit "; for cause in err.chain() { @@ -2935,7 +3092,9 @@ fn index_entry_path(entry: &git2::IndexEntry) -> anyhow::Result { .trim_end_matches('\0') .to_string()) } else { - Ok(String::from_utf8_lossy(raw).trim_end_matches('\0').to_string()) + Ok(String::from_utf8_lossy(raw) + .trim_end_matches('\0') + .to_string()) } } @@ -2944,7 +3103,9 @@ fn index_entry_stage(entry: &git2::IndexEntry) -> i32 { } fn as_text_or_binary(path: &str, data: Option<&Vec>) -> (Option, bool) { - let Some(bytes) = data else { return (None, false) }; + let Some(bytes) = data else { + return (None, false); + }; match std::str::from_utf8(bytes) { Ok(s) => (Some(s.to_string()), false), Err(_) => { @@ -2966,7 +3127,6 @@ fn as_text_or_binary(path: &str, data: Option<&Vec>) -> (Option, boo } } - fn extract_host(url: &str) -> Option { let s = url.trim(); let s = s @@ -3282,7 +3442,11 @@ fn normalize_repo_path(path: String) -> String { if trimmed.is_empty() { String::new() } else { - trimmed.replace('\\', "/") + trimmed + .replace('\\', "/") + .trim_start_matches("./") + .trim_start_matches('/') + .to_string() } } diff --git a/api/src/main.rs b/api/src/main.rs index 2ab544a1..c57b2c2e 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -707,6 +707,7 @@ async fn main() -> anyhow::Result<()> { git_storage.clone(), storage_resolver.clone(), snapshot_service_arc.clone(), + realtime_engine.clone(), )?, ); let git_service = Arc::new(GitService::new( diff --git a/api/src/presentation/http/git.rs b/api/src/presentation/http/git.rs index 3ab6eb72..ebf82a4e 100644 --- a/api/src/presentation/http/git.rs +++ b/api/src/presentation/http/git.rs @@ -12,8 +12,8 @@ use crate::presentation::http::auth::{Bearer, validate_bearer}; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ GitChangeItem as GitChangeDto, GitCommitInfo, GitConfigDto, GitPullRequestDto, - GitPullResolutionDto, GitStatusDto, GitSyncRequestDto, GitignoreUpdateDto, UpsertGitConfigInput, - GitPullSessionDto, + GitPullResolutionDto, GitPullSessionDto, GitStatusDto, GitSyncRequestDto, GitignoreUpdateDto, + UpsertGitConfigInput, }; use crate::application::services::errors::ServiceError; use crate::domain::workspaces::permissions::{PERM_GIT_CONFIGURE, PERM_GIT_INIT, PERM_GIT_SYNC}; @@ -22,7 +22,6 @@ use crate::presentation::http::workspace_scope; use tracing::error; use uuid::Uuid; - // Uses AppContext as router state pub fn routes(ctx: AppContext) -> Router { @@ -43,7 +42,10 @@ pub fn routes(ctx: AppContext) -> Router { .route("/git/pull/start", post(start_pull_session)) .route("/git/pull/session/:id", get(get_pull_session)) .route("/git/pull/session/:id/resolve", post(resolve_pull_session)) - .route("/git/pull/session/:id/finalize", post(finalize_pull_session)) + .route( + "/git/pull/session/:id/finalize", + post(finalize_pull_session), + ) .route("/git/init", post(init_repository)) .route("/git/deinit", post(deinit_repository)) .route("/git/ignore/doc/:id", post(ignore_document)) @@ -217,11 +219,7 @@ impl From for GitPullSessionResponse { Self { session_id: value.id, status: value.status, - conflicts: value - .conflicts - .into_iter() - .map(Into::into) - .collect(), + conflicts: value.conflicts.into_iter().map(Into::into).collect(), resolutions: value .resolutions .into_iter() @@ -701,7 +699,11 @@ pub async fn pull_repository( .map(|items| items.into_iter().map(Into::into).collect::>()) .unwrap_or_default(); let has_conflicts = !conflicts.is_empty(); - let status = if has_conflicts { StatusCode::CONFLICT } else { StatusCode::OK }; + let status = if has_conflicts { + StatusCode::CONFLICT + } else { + StatusCode::OK + }; Ok(( status, Json(GitPullResponse { @@ -747,8 +749,10 @@ pub async fn start_pull_session( Ok(v) => v, Err(err) => { let message = match &err { - ServiceError::BadRequest("workspace_has_pending_changes") => - "Workspace has pending changes. Commit, sync, or discard them before pulling.".to_string(), + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } other => other.to_string(), }; let status = map_git_error(err); @@ -778,19 +782,31 @@ pub async fn start_pull_session( .save_pull_session(GitPullSessionDto { id: session_id, workspace_id, - status: if has_conflicts { "pending".to_string() } else { "merged".to_string() }, + status: if has_conflicts { + "pending".to_string() + } else { + "merged".to_string() + }, conflicts: dto.conflicts.unwrap_or_default(), resolutions: Vec::new(), base_commit: dto.base_commit.clone(), remote_commit: dto.remote_commit.clone(), }) .await; - let status = if has_conflicts { StatusCode::CONFLICT } else { StatusCode::OK }; + let status = if has_conflicts { + StatusCode::CONFLICT + } else { + StatusCode::OK + }; Ok(( status, Json(GitPullSessionResponse { session_id, - status: if has_conflicts { "pending".to_string() } else { "merged".to_string() }, + status: if has_conflicts { + "pending".to_string() + } else { + "merged".to_string() + }, conflicts, resolutions: Vec::new(), message: None, @@ -945,8 +961,10 @@ pub async fn resolve_pull_session( Ok(v) => v, Err(err) => { let message = match &err { - ServiceError::BadRequest("workspace_has_pending_changes") => - "Workspace has pending changes. Commit, sync, or discard them before pulling.".to_string(), + ServiceError::BadRequest("workspace_has_pending_changes") => { + "Workspace has pending changes. Commit, sync, or discard them before pulling." + .to_string() + } other => other.to_string(), }; let status = map_git_error(err); @@ -992,7 +1010,11 @@ pub async fn resolve_pull_session( .save_pull_session(GitPullSessionDto { id, workspace_id, - status: if conflicts.is_empty() { "merged".to_string() } else { "resolving".to_string() }, + status: if conflicts.is_empty() { + "merged".to_string() + } else { + "resolving".to_string() + }, conflicts: dto.conflicts.unwrap_or_default(), resolutions: resolutions .iter() @@ -1012,7 +1034,11 @@ pub async fn resolve_pull_session( status_code, Json(GitPullSessionResponse { session_id: id, - status: if conflicts.is_empty() { "merged".to_string() } else { "resolving".to_string() }, + status: if conflicts.is_empty() { + "merged".to_string() + } else { + "resolving".to_string() + }, conflicts, resolutions, message: None, @@ -1078,7 +1104,10 @@ pub async fn finalize_pull_session( git_status: None, })); } - let git_status = service.get_status(workspace_id).await.map_err(map_git_error)?; + let git_status = service + .get_status(workspace_id) + .await + .map_err(map_git_error)?; let _ = service .save_pull_session(GitPullSessionDto { id, diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index a13aee44..a1461830 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -1,6 +1,6 @@ import { DiffEditor } from '@monaco-editor/react' import { AlertTriangle, Check, Loader2, SlidersHorizontal, X } from 'lucide-react' -import type * as monacoNs from 'monaco-editor' +import * as monacoNs from 'monaco-editor' import { useCallback, useMemo, useEffect, useRef, useState, type CSSProperties, type ReactNode, type MutableRefObject } from 'react' import { overlayPanelClass } from '@/shared/lib/overlay-classes' @@ -100,13 +100,25 @@ export function EditorLayout({ const monacoRef = useRef(null) const [diffReady, setDiffReady] = useState(false) const overlayNodesRef = useRef>({}) + const overlayWidgetsRef = useRef>({}) const overlayDisposablesRef = useRef([]) useEffect(() => { // cleanup helper const cleanup = () => { + if (diffEditorRef.current && monacoRef.current) { + const modified = diffEditorRef.current.getModifiedEditor() + Object.values(overlayWidgetsRef.current).forEach((widget) => { + try { + modified.removeContentWidget(widget) + } catch { + /* ignore */ + } + }) + } Object.values(overlayNodesRef.current).forEach((node) => node.remove()) overlayNodesRef.current = {} + overlayWidgetsRef.current = {} overlayDisposablesRef.current.forEach((d) => d.dispose()) overlayDisposablesRef.current = [] } @@ -166,6 +178,7 @@ export function EditorLayout({ node.style.pointerEvents = 'auto' node.style.whiteSpace = 'nowrap' node.style.zIndex = '50' + node.style.marginLeft = '8px' const makeBtn = (label: string, side: 'ours' | 'theirs') => { const btn = document.createElement('button') @@ -203,32 +216,40 @@ export function EditorLayout({ conflictHunkWidgets.forEach((hunk) => { const node = createNode(hunk) overlayNodesRef.current[hunk.id] = node - host.appendChild(node) + const widget: monacoNs.editor.IContentWidget = { + getId: () => `conflict-hunk-${hunk.id}`, + getDomNode: () => node, + getPosition: () => ({ + position: { + lineNumber: Math.max(hunk.line, 1), + // Place at line end so it follows text instead of gutter. + column: + (modified.getModel()?.getLineMaxColumn(Math.max(hunk.line, 1)) ?? 1) + + 1, + }, + preference: [monacoNs.editor.ContentWidgetPositionPreference.EXACT], + }), + } + overlayWidgetsRef.current[hunk.id] = widget + modified.addContentWidget(widget) }) - const updatePositions = () => { - const layout = modified.getLayoutInfo() - const lineHeight = modified.getOption(monacoInstance.editor.EditorOption.lineHeight) - const rightInset = - (layout.minimap?.renderMinimap ? layout.minimap.minimapWidth : 0) + - layout.verticalScrollbarWidth + - layout.glyphMarginWidth + - 12 - conflictHunkWidgets.forEach((hunk) => { - const node = overlayNodesRef.current[hunk.id] - if (!node) return - const top = modified.getTopForLineNumber(Math.max(hunk.line, 1)) + lineHeight - 2 - node.style.top = `${top}px` - node.style.right = `${rightInset}px` + const relayout = () => { + Object.values(overlayWidgetsRef.current).forEach((widget) => { + try { + modified.layoutContentWidget(widget) + } catch { + /* ignore */ + } }) } - updatePositions() + relayout() overlayDisposablesRef.current.push( - modified.onDidScrollChange(() => updatePositions()), - modified.onDidLayoutChange(() => updatePositions()), - modified.onDidChangeConfiguration(() => updatePositions()), + modified.onDidScrollChange(() => relayout()), + modified.onDidLayoutChange(() => relayout()), + modified.onDidChangeConfiguration(() => relayout()), ) return cleanup diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 90b97e01..13a099b7 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -425,7 +425,7 @@ function DocumentClient({ const handleConflictResolved = useCallback(() => { navigate({ - to: '/(app)/document/$id', + to: '/document/$id', params: { id }, search: (prev: Record) => { const next = { ...prev } From bfb97a6f2f204c050f0c86ed4df8b049fea1290b Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 11 Dec 2025 08:40:02 +0900 Subject: [PATCH 05/16] fix: sync --- api/src/infrastructure/git/workspace.rs | 208 +++++++++++++++++++----- 1 file changed, 169 insertions(+), 39 deletions(-) diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index 1c501c04..8504fbbe 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -1597,15 +1597,14 @@ impl GitWorkspacePort for GitWorkspaceService { self.ensure_storage_commit_integrity(workspace_id).await?; latest_meta = self.latest_commit_meta(workspace_id).await?; - let previous_index = latest_meta + let mut use_full_scan = force_full_scan || latest_meta.is_none(); + + let mut previous_index = latest_meta .as_ref() .map(|c| c.file_hash_index.clone()) .unwrap_or_default(); let dirty_rows = self.fetch_dirty(workspace_id).await?; - // Determine strategy: forced full scan or initial commit uses full state rebuild. - let use_full_scan = force_full_scan || latest_meta.is_none(); - // Build change sets from dirty rows let mut upserts: BTreeMap = BTreeMap::new(); let mut deletes: BTreeSet = BTreeSet::new(); @@ -1668,7 +1667,7 @@ impl GitWorkspacePort for GitWorkspaceService { let mut precomputed_upsert_bytes: BTreeMap> = BTreeMap::new(); let mut changed_text_snapshots: HashMap = HashMap::new(); let mut next_file_hash_index: HashMap = previous_index.clone(); - let files_changed_for_response: u32; + let mut files_changed_for_response: u32 = 0; if use_full_scan { let current = self.collect_current_state(workspace_id).await?; @@ -1749,20 +1748,28 @@ impl GitWorkspacePort for GitWorkspaceService { files_changed_for_response = (upserts.len() + deletes.len()) as u32; } - let previous_pack = if let Some(prev_meta) = latest_meta.as_ref() { - Some( - self.persist_pack_chain(workspace_id, Some(prev_meta.commit_id.as_slice())) - .await? - .ok_or_else(|| { - anyhow!( - "missing pack data for commit {}", - encode_commit_id(&prev_meta.commit_id) - ) - })?, - ) - } else { - None - }; + let mut previous_pack = None; + if let Some(prev_meta) = latest_meta.as_ref() { + match self + .persist_pack_chain(workspace_id, Some(prev_meta.commit_id.as_slice())) + .await? + { + Some(chain) => { + previous_pack = Some(chain); + } + None => { + // Pack chain missing or reset: fall back to full scan. + warn!( + workspace_id = %workspace_id, + commit = %encode_commit_id(&prev_meta.commit_id), + "git_sync_missing_pack_chain_fallback_full_scan" + ); + latest_meta = None; + previous_index.clear(); + use_full_scan = true; + } + } + } let (meta, pack_bytes, commit_hex, pushed, files_changed_for_response) = { let temp_dir = TempDirBuilder::new() @@ -1773,7 +1780,33 @@ impl GitWorkspacePort for GitWorkspaceService { if let Some((_, ref pack_paths)) = previous_pack { // Apply full chain to ensure delta bases are present - apply_pack_files(&repo, pack_paths)?; + if let Err(err) = apply_pack_files(&repo, pack_paths) { + let lower = err.to_string().to_lowercase(); + let missing_obj = lower.contains("missing") && lower.contains("object"); + if missing_obj { + warn!( + workspace_id = %workspace_id, + error = %err, + "git_sync_pack_missing_objects_fallback_full_scan" + ); + // Fallback: switch to full scan and rebuild tree from current state + let current = self.collect_current_state(workspace_id).await?; + let mut entries: BTreeMap> = BTreeMap::new(); + next_file_hash_index.clear(); + for (path, snapshot) in current.iter() { + let bytes = self.snapshot_bytes(snapshot).await?; + entries.insert(path.clone(), bytes); + next_file_hash_index.insert(path.clone(), snapshot.hash.clone()); + } + precomputed_full_entries = Some(entries); + files_changed_for_response = next_file_hash_index.len() as u32; + use_full_scan = true; + latest_meta = None; + previous_index.clear(); + } else { + return Err(err); + } + } } // Skip pre-fetch/verify to avoid remote redirect/auth loops; rely on push outcome. @@ -1822,6 +1855,10 @@ impl GitWorkspacePort for GitWorkspaceService { let mut pack_builder = repo.packbuilder()?; pack_builder.insert_commit(commit_oid)?; + // Include parent commit objects to avoid missing bases when applying packs later. + for parent in parent_commits.iter() { + pack_builder.insert_commit(parent.id())?; + } let mut pack_buf = git2::Buf::new(); pack_builder.write_buf(&mut pack_buf)?; let pack_bytes = pack_buf.to_vec(); @@ -2035,18 +2072,14 @@ impl GitWorkspacePort for GitWorkspaceService { cfg.branch_name.clone() }; - // Ensure remote history exists locally (best effort). - let _ = self - .bootstrap_remote_history(workspace_id, cfg, &branch) - .await; - - let latest_meta = self.latest_commit_meta(workspace_id).await?; - let base_index: HashMap = latest_meta + // Capture current workspace head before touching remote history. + let local_meta = self.latest_commit_meta(workspace_id).await?; + let base_index: HashMap = local_meta .as_ref() .map(|m| m.file_hash_index.clone()) .unwrap_or_default(); let previous_index = base_index.clone(); - let base_commit = latest_meta.as_ref().map(|m| m.commit_id.clone()); + let base_commit = local_meta.as_ref().map(|m| m.commit_id.clone()); let temp_dir = TempDirBuilder::new() .prefix("git-pull-") @@ -2056,7 +2089,7 @@ impl GitWorkspacePort for GitWorkspaceService { if let Some((_, pack_paths)) = self .persist_pack_chain( workspace_id, - latest_meta.as_ref().map(|m| m.commit_id.as_slice()), + local_meta.as_ref().map(|m| m.commit_id.as_slice()), ) .await? { @@ -2079,9 +2112,16 @@ impl GitWorkspacePort for GitWorkspaceService { }; let remote_commit = Some(remote_oid.as_bytes().to_vec()); - let local_oid = latest_meta + let mut local_oid = local_meta .as_ref() .and_then(|m| git2::Oid::from_bytes(&m.commit_id).ok()); + // If workspace has no local commit recorded (fresh pull), fall back to latest known meta after bootstrap. + if local_oid.is_none() { + local_oid = self + .latest_commit_meta(workspace_id) + .await? + .and_then(|m| git2::Oid::from_bytes(&m.commit_id).ok()); + } // Detect drift between latest commit and current workspace using the same dirty set as Git Changes/Status. let dirty_rows = self.fetch_dirty(workspace_id).await?; // Track paths that are dirty or differ from base to scope conflicts to overlapping files. @@ -2189,7 +2229,7 @@ impl GitWorkspacePort for GitWorkspaceService { } else { Some(Vec::new()) }; - let base_bytes = if let Some(meta) = latest_meta.as_ref() { + let base_bytes = if let Some(meta) = local_meta.as_ref() { self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), &path) .await? } else { @@ -2211,6 +2251,11 @@ impl GitWorkspacePort for GitWorkspaceService { }); } + // First-time pull with no local history and no dirty changes: allow fast-forward without forcing conflicts. + if local_meta.is_none() && dirty_rows.is_empty() { + remote_conflicts.clear(); + } + // If commits differ but no conflict paths were detected above, fallback to diff of current vs remote trees. if remote_conflicts.is_empty() { let local_oid_val = local_oid.unwrap_or(remote_oid); @@ -2239,12 +2284,12 @@ impl GitWorkspacePort for GitWorkspaceService { } else { Some(Vec::new()) }; - let base_bytes = if let Some(meta) = latest_meta.as_ref() { - self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), &path) - .await? - } else { - None - }; + let base_bytes = if let Some(meta) = local_meta.as_ref() { + self.load_file_snapshot(workspace_id, meta.commit_id.as_slice(), &path) + .await? + } else { + None + }; let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); @@ -2311,6 +2356,56 @@ impl GitWorkspacePort for GitWorkspaceService { }); } + // Ensure remote head commit metadata/pack exists locally for merge parent and future syncs. + let mut remote_pack: Option<(CommitMeta, Vec)> = None; + if self + .commit_meta_by_id(workspace_id, remote_oid.as_bytes()) + .await? + .is_none() + { + let remote_index: HashMap = remote_state + .iter() + .map(|(path, snap)| (path.clone(), snap.hash.clone())) + .collect(); + let (remote_meta, remote_pack_bytes) = { + let remote_commit_obj = repo.find_commit(remote_oid)?; + let committed_at = git_time_to_datetime(remote_commit_obj.time())?; + let message = remote_commit_obj + .message() + .map(|m| m.trim_end_matches('\n').to_string()) + .filter(|m| !m.trim().is_empty()); + let author = remote_commit_obj.author(); + let author_name = author.name().map(|s| s.to_string()); + let author_email = author.email().map(|s| s.to_string()); + let parent_commit_id = if remote_commit_obj.parent_count() > 0 { + let parent = remote_commit_obj.parent_id(0)?; + Some(parent.as_bytes().to_vec()) + } else { + None + }; + + let mut pack_builder = repo.packbuilder()?; + pack_builder.insert_commit(remote_oid)?; + let mut pack_buf = git2::Buf::new(); + pack_builder.write_buf(&mut pack_buf)?; + let pack_bytes = pack_buf.to_vec(); + + let commit_hex = encode_commit_id(remote_oid.as_bytes()); + let remote_meta = CommitMeta { + commit_id: remote_oid.as_bytes().to_vec(), + parent_commit_id, + message, + author_name, + author_email, + committed_at, + pack_key: format!("git/packs/{}/{}.pack", workspace_id, commit_hex), + file_hash_index: remote_index, + }; + (remote_meta, pack_bytes) + }; + remote_pack = Some((remote_meta, remote_pack_bytes)); + } + // Diverged: merge local into remote (linear, parent = remote) let Some(local_oid_val) = local_oid else { anyhow::bail!("no local commit to merge"); @@ -2474,13 +2569,16 @@ impl GitWorkspacePort for GitWorkspaceService { let tree_oid = build_tree_from_entries(&repo, &entry_map)?; let tree = repo.find_tree(tree_oid)?; let sig = signature_from_parts("RefMD", "refmd@example.com", chrono::Utc::now())?; + let base_parent = repo.find_commit(local_oid_val)?; + let remote_parent = repo.find_commit(remote_oid)?; + let parent_refs: [&git2::Commit; 2] = [&base_parent, &remote_parent]; let commit_oid = repo.commit( None, &sig, &sig, "Merge remote changes", &tree, - &[&remote_commit_obj], + &parent_refs, )?; let mut file_hash_index: HashMap = HashMap::new(); @@ -2490,6 +2588,9 @@ impl GitWorkspacePort for GitWorkspaceService { let mut pack_builder = repo.packbuilder()?; pack_builder.insert_commit(commit_oid)?; + // Include both parents to avoid missing bases when applying packs later. + pack_builder.insert_commit(base_parent.id())?; + pack_builder.insert_commit(remote_parent.id())?; let mut pack_buf = git2::Buf::new(); pack_builder.write_buf(&mut pack_buf)?; let pack_bytes = pack_buf.to_vec(); @@ -2497,7 +2598,8 @@ impl GitWorkspacePort for GitWorkspaceService { let commit_hex = encode_commit_id(commit_oid.as_bytes()); let meta = CommitMeta { commit_id: commit_oid.as_bytes().to_vec(), - parent_commit_id: Some(remote_oid.as_bytes().to_vec()), + // Keep workspace history linear: parent is previous workspace head. + parent_commit_id: base_commit.clone(), message: Some("Merge remote changes".to_string()), author_name: Some("RefMD".to_string()), author_email: Some("refmd@example.com".to_string()), @@ -2509,6 +2611,14 @@ impl GitWorkspacePort for GitWorkspaceService { (meta, pack_bytes, merged_snapshots, commit_hex) }; + // Persist remote parent if we created it above. + if let Some((remote_meta, remote_pack_bytes)) = remote_pack.take() { + self.git_storage + .store_pack(workspace_id, &remote_pack_bytes, &remote_meta) + .await?; + self.upsert_commit_record(workspace_id, &remote_meta).await?; + } + let snapshot_keys = self .store_commit_snapshots(workspace_id, &meta.commit_id, &merged_snapshots) .await?; @@ -2800,6 +2910,8 @@ impl GitWorkspaceService { } } Err(err) => { + let err_str = err.to_string(); + let is_missing_objects = err_str.to_lowercase().contains("missing") && err_str.to_lowercase().contains("object"); if attempts == 0 { if let Some(commit_hex) = missing_metadata_commit(&err) { match self @@ -2820,6 +2932,24 @@ impl GitWorkspaceService { } } } + // If pack is missing objects, fall back by resetting git storage pointer and DB history. + if is_missing_objects { + warn!( + workspace_id = %workspace_id, + error = %err, + "git_pack_missing_objects_detected_resetting_history" + ); + // Drop storage latest pointer and DB commits for this workspace. + let _ = self + .git_storage + .set_latest_commit(workspace_id, None) + .await; + let _ = sqlx::query("DELETE FROM git_commits WHERE workspace_id = $1") + .bind(workspace_id) + .execute(&self.pool) + .await; + return Ok(None); + } } return Err(err); } From 66b103460cb14a2439a4c83e840a9ec92533ec3b Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 11 Dec 2025 09:17:14 +0900 Subject: [PATCH 06/16] fix: conflict resolver --- .../documents/git_dirty_subscriber.rs | 1 + api/src/infrastructure/git/workspace.rs | 145 +++++++++++------- app/src/widgets/document/DocumentPage.tsx | 3 +- 3 files changed, 91 insertions(+), 58 deletions(-) diff --git a/api/src/infrastructure/documents/git_dirty_subscriber.rs b/api/src/infrastructure/documents/git_dirty_subscriber.rs index cad51b43..67704605 100644 --- a/api/src/infrastructure/documents/git_dirty_subscriber.rs +++ b/api/src/infrastructure/documents/git_dirty_subscriber.rs @@ -27,6 +27,7 @@ impl GitDirtyDocEventSubscriber { .map(|row| row.flatten()) } + #[allow(dead_code)] async fn desired_path(&self, doc_id: Uuid) -> anyhow::Result> { sqlx::query_scalar::<_, Option>( "SELECT desired_path FROM documents WHERE id = $1", diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index 8504fbbe..646ae850 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -1667,7 +1667,7 @@ impl GitWorkspacePort for GitWorkspaceService { let mut precomputed_upsert_bytes: BTreeMap> = BTreeMap::new(); let mut changed_text_snapshots: HashMap = HashMap::new(); let mut next_file_hash_index: HashMap = previous_index.clone(); - let mut files_changed_for_response: u32 = 0; + let mut files_changed_for_response: u32; if use_full_scan { let current = self.collect_current_state(workspace_id).await?; @@ -1771,7 +1771,7 @@ impl GitWorkspacePort for GitWorkspaceService { } } - let (meta, pack_bytes, commit_hex, pushed, files_changed_for_response) = { + let (meta, pack_bytes, commit_hex, pushed) = { let temp_dir = TempDirBuilder::new() .prefix("git-sync-") .tempdir() @@ -1900,13 +1900,7 @@ impl GitWorkspacePort for GitWorkspaceService { // files_changed_for_response computed earlier - ( - meta, - pack_bytes, - commit_hex, - pushed, - files_changed_for_response, - ) + (meta, pack_bytes, commit_hex, pushed) }; if let Some((dir, _)) = previous_pack { @@ -2124,34 +2118,7 @@ impl GitWorkspacePort for GitWorkspaceService { } // Detect drift between latest commit and current workspace using the same dirty set as Git Changes/Status. let dirty_rows = self.fetch_dirty(workspace_id).await?; - // Track paths that are dirty or differ from base to scope conflicts to overlapping files. - let mut dirty_paths: HashSet = HashSet::new(); - for d in dirty_rows.iter() { - if let Ok(rel) = repo_relative_path(&d.path) { - dirty_paths.insert(rel); - } - } - let mut drift_detected = !dirty_rows.is_empty(); let current_state = self.collect_current_state(workspace_id).await?; - let mut current_index: HashMap = HashMap::new(); - for (path, snapshot) in current_state.iter() { - current_index.insert(path.clone(), snapshot.hash.clone()); - if base_index.get(path) != Some(&snapshot.hash) { - drift_detected = true; - dirty_paths.insert(path.clone()); - } - } - - // Do not bail on drift: we preserve workspace edits by synthesizing an "ours" commit below. - if !drift_detected { - for path in base_index.keys() { - if !current_index.contains_key(path) { - drift_detected = true; - dirty_paths.insert(path.clone()); - break; - } - } - } // Build remote state directly from fetched pack (git2 tree), independent of DB meta. fn collect_remote_state( @@ -2236,10 +2203,15 @@ impl GitWorkspacePort for GitWorkspaceService { None }; - let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); - let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); - let (base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let (mut ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (mut theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (mut base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours = strip_front_matter_body(path.as_str(), ours); + theirs = strip_front_matter_body(path.as_str(), theirs); + base = strip_front_matter_body(path.as_str(), base); + } remote_conflicts.push(GitPullConflictItemDto { path: path.clone(), @@ -2506,15 +2478,21 @@ impl GitWorkspacePort for GitWorkspaceService { for (path, ours_bytes, theirs_bytes, base_bytes) in conflict_entries { let resolution = resolution_map.get(&path); if resolution.is_none() { - let (ours_txt, ours_bin) = + let (mut ours_txt, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); - let (theirs_txt, theirs_bin) = + let (mut theirs_txt, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); - let (base_txt, base_bin) = + let (mut base_txt, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours_txt = strip_front_matter_body(path.as_str(), ours_txt); + theirs_txt = strip_front_matter_body(path.as_str(), theirs_txt); + base_txt = strip_front_matter_body(path.as_str(), base_txt); + } unresolved.push(GitPullConflictItemDto { path: path.clone(), - is_binary: ours_bin || theirs_bin || base_bin, + is_binary, ours: ours_txt, theirs: theirs_txt, base: base_txt, @@ -3118,20 +3096,68 @@ fn apply_pack_to_repo(repo: &Repository, pack: &[u8]) -> anyhow::Result<()> { Ok(()) } +fn find_front_matter_end(s: &str) -> Option<(usize, usize)> { + let bytes = s.as_bytes(); + let mut idx = 0; + while idx < bytes.len() { + if bytes[idx] == b'\n' { + let after_newline = &s[idx + 1..]; + if after_newline.starts_with("---") { + let mut body_start = idx + 1 + 3; + let mut remainder = &s[body_start..]; + // Skip trailing newlines after the closing delimiter to mirror ingest. + while remainder.starts_with("\r\n") || remainder.starts_with('\n') { + if remainder.starts_with("\r\n") { + body_start += 2; + remainder = &s[body_start..]; + } else { + body_start += 1; + remainder = &s[body_start..]; + } + } + return Some((idx, body_start)); + } + } + idx += 1; + } + None +} + +fn split_front_matter(input: &str) -> Option<(&str, &str)> { + let Some(after_open) = input + .strip_prefix("---\r\n") + .or_else(|| input.strip_prefix("---\n")) + else { + return None; + }; + if let Some((front_len, body_start)) = find_front_matter_end(after_open) { + let front = &after_open[..front_len]; + let body = &after_open[body_start..]; + return Some((front, body)); + } + None +} + +fn strip_front_matter_body(path: &str, text: Option) -> Option { + let Some(txt) = text else { + return None; + }; + let lower = path.to_ascii_lowercase(); + let is_markdown = lower.ends_with(".md") || lower.ends_with(".markdown"); + if !is_markdown { + return Some(txt); + } + if let Some((_, body)) = split_front_matter(txt.as_str()) { + return Some(body.to_string()); + } + Some(txt) +} + fn extract_markdown_body(bytes: &[u8]) -> Option { let text = std::str::from_utf8(bytes).ok()?; let trimmed = text.trim_start_matches('\u{feff}'); - if let Some(rest) = trimmed - .strip_prefix("---\n") - .or_else(|| trimmed.strip_prefix("---\r\n")) - { - let delimiters = ["\n---\n", "\r\n---\r\n", "\n---\r\n", "\r\n---\n"]; - for delim in delimiters { - if let Some(pos) = rest.find(delim) { - let body = &rest[pos + delim.len()..]; - return Some(body.to_string()); - } - } + if let Some((_, body)) = split_front_matter(trimmed) { + return Some(body.to_string()); } Some(trimmed.to_string()) } @@ -3193,10 +3219,15 @@ fn collect_conflicts( let theirs_bytes = to_bytes(conflict.their.as_ref())?; let base_bytes = to_bytes(conflict.ancestor.as_ref())?; - let (ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); - let (theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); - let (base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); + let (mut ours, ours_bin) = as_text_or_binary(path.as_str(), ours_bytes.as_ref()); + let (mut theirs, theirs_bin) = as_text_or_binary(path.as_str(), theirs_bytes.as_ref()); + let (mut base, base_bin) = as_text_or_binary(path.as_str(), base_bytes.as_ref()); let is_binary = ours_bin || theirs_bin || base_bin; + if !is_binary { + ours = strip_front_matter_body(path.as_str(), ours); + theirs = strip_front_matter_body(path.as_str(), theirs); + base = strip_front_matter_body(path.as_str(), base); + } out.push(GitPullConflictItemDto { path, diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 13a099b7..4442cb85 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -662,6 +662,7 @@ function DocumentClient({ const renderSecondaryViewer = secondaryViewerRenderer const oursText = activeConflict?.ours ?? '' + const theirsText = activeConflict?.theirs ?? '' const isBinaryConflict = activeConflict?.is_binary ?? false const hunkCount = useMemo(() => hunks.length, [hunks]) @@ -720,7 +721,7 @@ function DocumentClient({ }, onTakeTheirs: () => { setAllHunks('theirs') - setModifiedText(activeConflict?.theirs ?? '') + setModifiedText(theirsText) }, onApplyMerged: () => { handleApplyResolution('custom_text', modifiedText) From 5587c444a2ae0c96dbfc341ba175fa638f369f90 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 11 Dec 2025 10:16:03 +0900 Subject: [PATCH 07/16] update: conflict ui --- app/src/features/edit-document/ui/Editor.tsx | 6 ++ .../edit-document/ui/EditorLayout.tsx | 18 +++- app/src/widgets/document/DocumentPage.tsx | 92 ++++++++++++++----- 3 files changed, 89 insertions(+), 27 deletions(-) diff --git a/app/src/features/edit-document/ui/Editor.tsx b/app/src/features/edit-document/ui/Editor.tsx index 71fd652b..1d3e5f1b 100644 --- a/app/src/features/edit-document/ui/Editor.tsx +++ b/app/src/features/edit-document/ui/Editor.tsx @@ -63,6 +63,7 @@ export type MarkdownEditorProps = { choice?: 'ours' | 'theirs' onChoose: (side: 'ours' | 'theirs') => void }> + conflictBadgeText?: string conflictView?: { kind: 'text' | 'binary' original?: string @@ -76,6 +77,7 @@ export type MarkdownEditorProps = { } theme?: string } + previewOverride?: string renderPreview?: (props: PreviewPaneProps) => React.ReactNode } @@ -92,7 +94,9 @@ export function MarkdownEditor(props: MarkdownEditorProps) { extraRight, conflictControls, conflictHunkWidgets, + conflictBadgeText, conflictView, + previewOverride, renderPreview, } = props const { isDarkMode } = useTheme() @@ -569,12 +573,14 @@ export function MarkdownEditor(props: MarkdownEditorProps) { onPreviewNavigate={onPreviewNavigate} documentId={documentId} onToggleTask={handleTaskToggle} + previewContentOverride={previewOverride} content={boundText} vimStatusBarRef={vimStatusBarRef} showVimStatusBar={isVimMode} uploadStatus={uploadStatus} renderPreview={renderPreview} conflictControls={conflictControls} + conflictBadgeText={conflictBadgeText} conflictHunkWidgets={conflictHunkWidgets} conflictView={ conflictView diff --git a/app/src/features/edit-document/ui/EditorLayout.tsx b/app/src/features/edit-document/ui/EditorLayout.tsx index a1461830..adf6816e 100644 --- a/app/src/features/edit-document/ui/EditorLayout.tsx +++ b/app/src/features/edit-document/ui/EditorLayout.tsx @@ -36,6 +36,7 @@ export type EditorLayoutProps = { documentId: string onToggleTask?: (lineNumber: number, checked: boolean) => void content: string + previewContentOverride?: string vimStatusBarRef: MutableRefObject showVimStatusBar: boolean uploadStatus: UploadStatus @@ -43,6 +44,7 @@ export type EditorLayoutProps = { editorOverlay?: ReactNode editorBanner?: ReactNode conflictControls?: ReactNode + conflictBadgeText?: string conflictHunkWidgets?: Array<{ id: string line: number @@ -86,6 +88,7 @@ export function EditorLayout({ documentId, onToggleTask, content, + previewContentOverride, vimStatusBarRef, showVimStatusBar, uploadStatus, @@ -93,6 +96,7 @@ export function EditorLayout({ editorOverlay, editorBanner, conflictControls, + conflictBadgeText, conflictHunkWidgets, conflictView, }: EditorLayoutProps) { @@ -423,7 +427,8 @@ export function EditorLayout({
{conflictView && conflictView.kind === 'text' ? ( -
+
+ {conflictControls ?
{conflictControls}
: null}
- {conflictControls ?
{conflictControls}
: null} + {conflictHunkWidgets && conflictHunkWidgets.length ? ( +
+
+ + {conflictBadgeText || `${conflictHunkWidgets.length} hunks`} +
+
+ ) : null}
) : conflictView && conflictView.kind === 'binary' ? (
@@ -523,7 +535,7 @@ export function EditorLayout({ > {(() => { const previewProps: PreviewPaneProps = { - content, + content: previewContentOverride ?? content, forceFloatingToc: layoutState.shouldForceFloatingToc, viewMode: view === 'split' ? 'split' : 'preview', onNavigate: onPreviewNavigate, diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 4442cb85..39dcf0e1 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -263,10 +263,11 @@ function DocumentClient({ const [savingShare, setSavingShare] = useState(false) const [activeConflict, setActiveConflict] = useState(null) const [modifiedText, setModifiedText] = useState('') + const [previewContent, setPreviewContent] = useState('') const [segments, setSegments] = useState([]) const [hunks, setHunks] = useState([]) const [hunkChoices, setHunkChoices] = useState>({}) - const [hunkDefaultSide, setHunkDefaultSide] = useState<'ours' | 'theirs'>('theirs') + const [hunkDefaultSide, setHunkDefaultSide] = useState<'ours' | 'theirs'>('ours') const [hunkAnchors, setHunkAnchors] = useState>([]) const lastPayloadRef = useRef([]) const { secondaryDocumentId, secondaryDocumentType, showSecondaryViewer, closeSecondaryViewer, openSecondaryViewer } = useSecondaryViewer() @@ -326,10 +327,11 @@ function DocumentClient({ setSegments(segs) setHunks(nextHunks) setHunkChoices({}) - setHunkDefaultSide('theirs') - // Show remote side by default so Monaco diff highlights per-hunk differences. - setModifiedText(theirsText || oursText) - setHunkAnchors(buildHunkAnchors(segs, {}, 'theirs')) + setHunkDefaultSide('ours') + // Show local content by default; users can flip hunks to remote. + setModifiedText(oursText || theirsText) + setHunkAnchors(buildHunkAnchors(segs, {}, 'ours')) + setPreviewContent(oursText) } else { setSegments([]) setHunks([]) @@ -337,6 +339,7 @@ function DocumentClient({ setHunkDefaultSide('ours') setModifiedText(matched?.theirs ?? matched?.ours ?? '') setHunkAnchors([]) + setPreviewContent('') } }, [loaderData?.desired_path, loaderData?.path], @@ -373,6 +376,7 @@ function DocumentClient({ if (!segments.length) return setModifiedText(buildMergedText(segments, hunkChoices, hunkDefaultSide)) setHunkAnchors(buildHunkAnchors(segments, hunkChoices, hunkDefaultSide)) + setPreviewContent(buildMergedText(segments, hunkChoices, hunkDefaultSide)) }, [segments, hunkChoices, hunkDefaultSide]) const openDownloadDialog = useCallback(() => { @@ -683,6 +687,16 @@ function DocumentClient({ [hunks], ) + const applyGlobalChoice = useCallback( + (side: 'ours' | 'theirs') => { + setAllHunks(side) + const nextText = side === 'theirs' ? theirsText : oursText + setModifiedText(nextText) + setPreviewContent(nextText) + }, + [oursText, setAllHunks, setModifiedText, setPreviewContent, theirsText], + ) + const handleApplyResolution = useCallback( (choice: GitPullResolution['choice'], customContent?: string) => { if (!activeConflict) return @@ -711,17 +725,22 @@ function DocumentClient({ kind: isBinaryConflict ? 'binary' as const : 'text' as const, original: oursText, modified: modifiedText, - onChange: setModifiedText, + onChange: (val: string) => { + setModifiedText(val) + setPreviewContent(val) + }, readOnly: pullMutation.isPending, actions: !isBinaryConflict ? { onKeepMine: () => { setAllHunks('ours') setModifiedText(oursText) + setPreviewContent(oursText) }, onTakeTheirs: () => { setAllHunks('theirs') setModifiedText(theirsText) + setPreviewContent(theirsText) }, onApplyMerged: () => { handleApplyResolution('custom_text', modifiedText) @@ -731,6 +750,46 @@ function DocumentClient({ } : undefined + const conflictControls = showConflictUI + ? ( +
+ {!isBinaryConflict ? ( + <> + + + + + ) : null} +
+ ) + : null + + const conflictBadgeText = !isBinaryConflict ? `${resolvedHunks}/${hunkCount} decided` : undefined + const conflictHunkWidgets = showConflictUI && activeConflict && !isBinaryConflict && hunks.length ? hunkAnchors.map((anchor) => ({ @@ -743,24 +802,6 @@ function DocumentClient({ return (
- {showConflictUI && activeConflict ? ( -
- - - {isBinaryConflict ? 'Binary conflict' : `${hunkCount} hunks (${resolvedHunks} decided)`} - - {!isBinaryConflict ? ( - - ) : null} -
- ) : null} {showOverlay && } {showEditor ? ( setShowBacklinks(false)} /> From 3211e4f82a0c772cef7eb775f4739bf1fb0a6305 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 11 Dec 2025 10:35:52 +0900 Subject: [PATCH 08/16] update: git pull dialog --- .../features/git-sync/ui/git-pull-dialog.tsx | 91 ++++++------------- .../features/git-sync/ui/git-sync-button.tsx | 1 - app/src/widgets/document/DocumentPage.tsx | 16 +++- 3 files changed, 38 insertions(+), 70 deletions(-) diff --git a/app/src/features/git-sync/ui/git-pull-dialog.tsx b/app/src/features/git-sync/ui/git-pull-dialog.tsx index b9bade8e..74fde50b 100644 --- a/app/src/features/git-sync/ui/git-pull-dialog.tsx +++ b/app/src/features/git-sync/ui/git-pull-dialog.tsx @@ -1,7 +1,8 @@ -import { AlertTriangle, Loader2 } from 'lucide-react' -import React from 'react' - -import type { GitPullConflictItem, GitPullResolution } from '@/shared/api' +import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react' +import { Link } from '@tanstack/react-router' +import type { GitPullConflictItem } from '@/shared/api' +import { overlayPanelClass } from '@/shared/lib/overlay-classes' +import { cn } from '@/shared/lib/utils' import { Button } from '@/shared/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/shared/ui/dialog' @@ -10,41 +11,15 @@ type Props = { onOpenChange: (open: boolean) => void conflicts: GitPullConflictItem[] isLoading: boolean - onResolve: (resolutions: GitPullResolution[]) => void onRetry?: () => void emptyWarning?: boolean sessionId?: string | null } -export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onResolve, onRetry, emptyWarning, sessionId }: Props) { - const [choices, setChoices] = React.useState>({}) - - React.useEffect(() => { - if (!open) { - setChoices({}) - } - }, [open]) - - const allResolved = conflicts.length === 0 || conflicts.every((c) => choices[c.path]) - - const handleSubmit = () => { - if (!conflicts.length) { - onOpenChange(false) - return - } - const resolutions: GitPullResolution[] = conflicts - .map((c) => { - const choice = choices[c.path] - if (!choice) return null - return { path: c.path, choice } - }) - .filter(Boolean) as GitPullResolution[] - onResolve(resolutions) - } - +export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading, onRetry, emptyWarning, sessionId }: Props) { return ( - + @@ -88,44 +63,36 @@ export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading
) : ( conflicts.map((conflict) => { - const choice = choices[conflict.path] + const docId = conflict.document_id + const conflictLink = docId ? { id: docId } : null return (
-
+
{conflict.path}
-
- - -
+ )}
{!conflict.is_binary ? (

- Text file. Choose the side to keep. + Text conflict. Open the document to resolve hunks, then apply merge.

) : (

@@ -145,12 +112,6 @@ export default function GitPullDialog({ open, onOpenChange, conflicts, isLoading > Close - diff --git a/app/src/features/git-sync/ui/git-sync-button.tsx b/app/src/features/git-sync/ui/git-sync-button.tsx index 7a132936..c9355bb1 100644 --- a/app/src/features/git-sync/ui/git-sync-button.tsx +++ b/app/src/features/git-sync/ui/git-sync-button.tsx @@ -460,7 +460,6 @@ export default function GitSyncButton({ className, compact = false }: Props) { isLoading={pullMutation.isPending} emptyWarning={emptyConflictWarning} sessionId={sessionId} - onResolve={(resolutions) => pullMutation.mutate({ resolutions })} onRetry={() => pullMutation.mutate({ resolutions: [] })} /> diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 39dcf0e1..0c7078cb 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -264,6 +264,7 @@ function DocumentClient({ const [activeConflict, setActiveConflict] = useState(null) const [modifiedText, setModifiedText] = useState('') const [previewContent, setPreviewContent] = useState('') + const [hasInteracted, setHasInteracted] = useState(false) const [segments, setSegments] = useState([]) const [hunks, setHunks] = useState([]) const [hunkChoices, setHunkChoices] = useState>({}) @@ -328,10 +329,11 @@ function DocumentClient({ setHunks(nextHunks) setHunkChoices({}) setHunkDefaultSide('ours') - // Show local content by default; users can flip hunks to remote. - setModifiedText(oursText || theirsText) + // Default merge is ours, but show diff against remote by setting modified to theirs initially. + setModifiedText(theirsText || oursText) setHunkAnchors(buildHunkAnchors(segs, {}, 'ours')) setPreviewContent(oursText) + setHasInteracted(false) } else { setSegments([]) setHunks([]) @@ -340,6 +342,7 @@ function DocumentClient({ setModifiedText(matched?.theirs ?? matched?.ours ?? '') setHunkAnchors([]) setPreviewContent('') + setHasInteracted(false) } }, [loaderData?.desired_path, loaderData?.path], @@ -374,10 +377,12 @@ function DocumentClient({ useEffect(() => { if (!segments.length) return - setModifiedText(buildMergedText(segments, hunkChoices, hunkDefaultSide)) + if (hasInteracted) { + setModifiedText(buildMergedText(segments, hunkChoices, hunkDefaultSide)) + } setHunkAnchors(buildHunkAnchors(segments, hunkChoices, hunkDefaultSide)) setPreviewContent(buildMergedText(segments, hunkChoices, hunkDefaultSide)) - }, [segments, hunkChoices, hunkDefaultSide]) + }, [segments, hunkChoices, hunkDefaultSide, hasInteracted]) const openDownloadDialog = useCallback(() => { if (!hasDoc) return @@ -726,6 +731,7 @@ function DocumentClient({ original: oursText, modified: modifiedText, onChange: (val: string) => { + setHasInteracted(true) setModifiedText(val) setPreviewContent(val) }, @@ -733,11 +739,13 @@ function DocumentClient({ actions: !isBinaryConflict ? { onKeepMine: () => { + setHasInteracted(true) setAllHunks('ours') setModifiedText(oursText) setPreviewContent(oursText) }, onTakeTheirs: () => { + setHasInteracted(true) setAllHunks('theirs') setModifiedText(theirsText) setPreviewContent(theirsText) From d332599537ccce3012ec615b7ac23703a7fa2f95 Mon Sep 17 00:00:00 2001 From: munenick Date: Thu, 11 Dec 2025 11:35:06 +0900 Subject: [PATCH 09/16] feat: import from git --- api/openapi/openapi.json | 2 +- api/src/application/dto/git.rs | 9 + api/src/application/ports/git_workspace.rs | 11 +- api/src/application/services/git.rs | 35 ++ api/src/application/services/git_rebuild.rs | 15 + api/src/bin/export-openapi.rs | 2 + api/src/bin/refmd.rs | 9 + api/src/infrastructure/git/workspace.rs | 333 +++++++++++++++++- api/src/main.rs | 1 + api/src/presentation/http/git.rs | 55 +++ app/src/entities/git/api/index.ts | 14 +- app/src/features/auth/lib/types.ts | 2 +- .../features/git-sync/ui/git-pull-dialog.tsx | 3 +- app/src/shared/api/client/sdk.gen.ts | 17 +- app/src/shared/api/client/types.gen.ts | 15 + app/src/widgets/document/DocumentPage.tsx | 8 +- app/src/widgets/routes/PluginFallback.tsx | 7 +- app/src/widgets/settings/GitSyncPage.tsx | 45 ++- app/src/widgets/sidebar/FileTree.tsx | 11 +- app/src/widgets/workspaces/WorkspacesPage.tsx | 122 ++++++- 20 files changed, 689 insertions(+), 27 deletions(-) diff --git a/api/openapi/openapi.json b/api/openapi/openapi.json index 1abe5e61..d8cd06a8 100644 --- a/api/openapi/openapi.json +++ b/api/openapi/openapi.json @@ -1 +1 @@ -{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} +{"openapi":"3.0.3","info":{"title":"api","description":"","license":{"name":""},"version":"0.1.0"},"paths":{"/api/auth/login":{"post":{"tags":["Auth"],"operationId":"login","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/logout":{"post":{"tags":["Auth"],"operationId":"logout","responses":{"204":{"description":""}}}},"/api/auth/me":{"get":{"tags":["Auth"],"operationId":"me","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}}},"delete":{"tags":["Auth"],"operationId":"delete_account","responses":{"204":{"description":""}}}},"/api/auth/oauth/{provider}":{"post":{"tags":["Auth"],"operationId":"oauth_login","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier (e.g., google)","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthLoginRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/LoginResponse"}}}}},"security":[{}]}},"/api/auth/oauth/{provider}/state":{"post":{"tags":["Auth"],"operationId":"oauth_state","parameters":[{"name":"provider","in":"path","description":"OAuth provider identifier","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OAuthStateResponse"}}}}},"security":[{}]}},"/api/auth/providers":{"get":{"tags":["Auth"],"operationId":"list_oauth_providers","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AuthProvidersResponse"}}}}},"security":[{}]}},"/api/auth/refresh":{"post":{"tags":["Auth"],"operationId":"refresh_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RefreshResponse"}}}}}}},"/api/auth/register":{"post":{"tags":["Auth"],"operationId":"register","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RegisterRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserResponse"}}}}},"security":[{}]}},"/api/auth/sessions":{"get":{"tags":["Auth"],"operationId":"list_sessions","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SessionResponse"}}}}}}}},"/api/auth/sessions/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_session","parameters":[{"name":"id","in":"path","description":"Session ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/documents":{"get":{"tags":["Documents"],"operationId":"list_documents","parameters":[{"name":"query","in":"query","description":"Search query","required":false,"schema":{"type":"string","nullable":true}},{"name":"tag","in":"query","description":"Filter by tag","required":false,"schema":{"type":"string","nullable":true}},{"name":"state","in":"query","description":"Filter by document state (active|archived|all)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/DocumentListResponse"}}}}}},"post":{"tags":["Documents"],"operationId":"create_document","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/search":{"get":{"tags":["Documents"],"operationId":"search_documents","parameters":[{"name":"q","in":"query","description":"Query","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/SearchResult"}}}}}}}},"/api/documents/{id}":{"get":{"tags":["Documents"],"operationId":"get_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"delete":{"tags":["Documents"],"operationId":"delete_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Documents"],"operationId":"update_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/archive":{"post":{"tags":["Documents"],"operationId":"archive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document already archived"}}}},"/api/documents/{id}/backlinks":{"get":{"tags":["Documents"],"operationId":"getBacklinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BacklinksResponse"}}}}}}},"/api/documents/{id}/content":{"get":{"tags":["Documents"],"operationId":"get_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":""}}},"put":{"tags":["Documents"],"operationId":"update_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}},"patch":{"tags":["Documents"],"operationId":"patch_document_content","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/PatchDocumentContentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/download":{"get":{"tags":["Documents"],"operationId":"download_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"format","in":"query","description":"Download format (see schema for supported values)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Document download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Document not found"}}}},"/api/documents/{id}/duplicate":{"post":{"tags":["Documents"],"operationId":"duplicate_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/DuplicateDocumentRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/documents/{id}/links":{"get":{"tags":["Documents"],"operationId":"getOutgoingLinks","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/OutgoingLinksResponse"}}}}}}},"/api/documents/{id}/snapshots":{"get":{"tags":["Documents"],"operationId":"list_document_snapshots","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"limit","in":"query","description":"Maximum number of snapshots to return","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset for pagination","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotListResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/diff":{"get":{"tags":["Documents"],"operationId":"get_document_snapshot_diff","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}},{"name":"compare","in":"query","description":"Snapshot ID to compare against (defaults to current document state)","required":false,"schema":{"type":"string","format":"uuid","nullable":true}},{"name":"base","in":"query","description":"Base comparison to use when compare is not provided (auto|current|previous)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/SnapshotDiffBaseParam"}],"nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotDiffResponse"}}}}}}},"/api/documents/{id}/snapshots/{snapshot_id}/download":{"get":{"tags":["Documents"],"operationId":"download_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"Snapshot archive","content":{"application/zip":{"schema":{"$ref":"#/components/schemas/DocumentArchiveBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Snapshot not found"}}}},"/api/documents/{id}/snapshots/{snapshot_id}/restore":{"post":{"tags":["Documents"],"operationId":"restore_document_snapshot","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"snapshot_id","in":"path","description":"Snapshot ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"token","in":"query","description":"Share token (optional)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SnapshotRestoreResponse"}}}}}}},"/api/documents/{id}/unarchive":{"post":{"tags":["Documents"],"operationId":"unarchive_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}},"404":{"description":"Document not found"},"409":{"description":"Document is not archived"}}}},"/api/files":{"post":{"tags":["Files"],"summary":"POST /api/files (multipart/form-data)","description":"Fields:\n- file: binary file (required)\n- document_id: uuid (required by current schema)","operationId":"upload_file","requestBody":{"content":{"multipart/form-data":{"schema":{"$ref":"#/components/schemas/UploadFileMultipart"}}},"required":true},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UploadFileResponse"}}}}}}},"/api/files/documents/{filename}":{"get":{"tags":["Files"],"summary":"GET /api/files/documents/{filename}?document_id=uuid -> bytes","operationId":"get_file_by_name","parameters":[{"name":"filename","in":"path","description":"File name","required":true,"schema":{"type":"string"}},{"name":"document_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/files/{id}":{"get":{"tags":["Files"],"summary":"GET /api/files/{id} -> bytes (fallback; primary is /uploads/{filename})","operationId":"get_file","parameters":[{"name":"id","in":"path","description":"File ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/octet-stream":{"schema":{"type":"string","format":"binary"}}}}}}},"/api/git/changes":{"get":{"tags":["Git"],"operationId":"get_changes","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitChangesResponse"}}}}}}},"/api/git/config":{"get":{"tags":["Git"],"operationId":"get_config","responses":{"200":{"description":"","content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/GitConfigResponse"}],"nullable":true}}}}}},"post":{"tags":["Git"],"operationId":"create_or_update_config","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitConfigResponse"}}}}}},"delete":{"tags":["Git"],"operationId":"delete_config","responses":{"204":{"description":"Deleted"}}}},"/api/git/deinit":{"post":{"tags":["Git"],"operationId":"deinit_repository","responses":{"200":{"description":"OK"}}}},"/api/git/diff/commits/{from}/{to}":{"get":{"tags":["Git"],"operationId":"get_commit_diff","parameters":[{"name":"from","in":"path","description":"From","required":true,"schema":{"type":"string"}},{"name":"to","in":"path","description":"To","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/diff/working":{"get":{"tags":["Git"],"operationId":"get_working_diff","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffResult"}}}}}}}},"/api/git/gitignore/check":{"post":{"tags":["Git"],"operationId":"check_path_ignored","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CheckIgnoredRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/gitignore/patterns":{"get":{"tags":["Git"],"operationId":"get_gitignore_patterns","responses":{"200":{"description":"OK"}}},"post":{"tags":["Git"],"operationId":"add_gitignore_patterns","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/AddPatternsRequest"}}},"required":true},"responses":{"200":{"description":"OK"}}}},"/api/git/history":{"get":{"tags":["Git"],"operationId":"get_history","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitHistoryResponse"}}}}}}},"/api/git/ignore/doc/{id}":{"post":{"tags":["Git"],"operationId":"ignore_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/ignore/folder/{id}":{"post":{"tags":["Git"],"operationId":"ignore_folder","parameters":[{"name":"id","in":"path","description":"Folder ID","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"OK"}}}},"/api/git/import":{"post":{"tags":["Git"],"operationId":"import_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateGitConfigRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitImportResponse"}}}}}}},"/api/git/init":{"post":{"tags":["Git"],"operationId":"init_repository","responses":{"200":{"description":"OK"}}}},"/api/git/pull":{"post":{"tags":["Git"],"operationId":"pull_repository","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}":{"get":{"tags":["Git"],"operationId":"get_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/session/{id}/finalize":{"post":{"tags":["Git"],"operationId":"finalize_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullResponse"}}}}}}},"/api/git/pull/session/{id}/resolve":{"post":{"tags":["Git"],"operationId":"resolve_pull_session","parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/pull/start":{"post":{"tags":["Git"],"operationId":"start_pull_session","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}},"409":{"description":"Conflicts detected","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitPullSessionResponse"}}}}}}},"/api/git/status":{"get":{"tags":["Git"],"operationId":"get_status","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitStatus"}}}}}}},"/api/git/sync":{"post":{"tags":["Git"],"operationId":"sync_now","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/GitSyncResponse"}}}},"409":{"description":"Conflicts during rebase/pull"}}}},"/api/health":{"get":{"tags":["Health"],"operationId":"health","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/HealthResp"}}}}}}},"/api/markdown/render":{"post":{"tags":["Markdown"],"operationId":"render_markdown","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderResponseBody"}}}}}}},"/api/markdown/render-many":{"post":{"tags":["Markdown"],"operationId":"render_markdown_many","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RenderManyResponse"}}}}}}},"/api/me/api-tokens":{"get":{"tags":["Auth"],"operationId":"list_api_tokens","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApiTokenItem"}}}}}}},"post":{"tags":["Auth"],"operationId":"create_api_token","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ApiTokenCreateResponse"}}}}}}},"/api/me/api-tokens/{id}":{"delete":{"tags":["Auth"],"operationId":"revoke_api_token","parameters":[{"name":"id","in":"path","description":"Token ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/me/plugins/install-from-url":{"post":{"tags":["Plugins"],"operationId":"pluginsInstallFromUrl","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallFromUrlBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/InstallResponse"}}}}}}},"/api/me/plugins/manifest":{"get":{"tags":["Plugins"],"operationId":"pluginsGetManifest","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ManifestItem"}}}}}}}},"/api/me/plugins/uninstall":{"post":{"tags":["Plugins"],"operationId":"pluginsUninstall","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UninstallBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/me/plugins/updates":{"get":{"tags":["Plugins"],"operationId":"sse_updates","responses":{"200":{"description":"Plugin event stream"}}}},"/api/me/shortcuts":{"get":{"tags":["Auth"],"operationId":"get_user_shortcuts","responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}},"put":{"tags":["Auth"],"operationId":"update_user_shortcuts","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateUserShortcutRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/UserShortcutResponse"}}}}}}},"/api/plugins/{plugin}/docs/{doc_id}/kv/{key}":{"get":{"tags":["Plugins"],"operationId":"pluginsGetKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueResponse"}}}}}},"put":{"tags":["Plugins"],"operationId":"pluginsPutKv","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"key","in":"path","description":"Key","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/KvValueBody"}}},"required":true},"responses":{"204":{"description":""}}}},"/api/plugins/{plugin}/docs/{doc_id}/records/{kind}":{"get":{"tags":["Plugins"],"operationId":"list_records","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}},{"name":"limit","in":"query","description":"Limit","required":false,"schema":{"type":"integer","format":"int64","nullable":true}},{"name":"offset","in":"query","description":"Offset","required":false,"schema":{"type":"integer","format":"int64","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/RecordsResponse"}}}}}},"post":{"tags":["Plugins"],"operationId":"pluginsCreateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"doc_id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"kind","in":"path","description":"Record kind","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/plugins/{plugin}/exec/{action}":{"post":{"tags":["Plugins"],"operationId":"pluginsExecAction","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"action","in":"path","description":"Action","required":true,"schema":{"type":"string"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ExecResultResponse"}}}}}}},"/api/plugins/{plugin}/records/{id}":{"delete":{"tags":["Plugins"],"operationId":"pluginsDeleteRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Plugins"],"operationId":"pluginsUpdateRecord","parameters":[{"name":"plugin","in":"path","description":"Plugin ID","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Record ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateRecordBody"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{}}}}}}},"/api/public/documents/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_publish_status","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"post":{"tags":["Public Documents"],"operationId":"publish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Published","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PublishResponse"}}}}}},"delete":{"tags":["Public Documents"],"operationId":"unpublish_document","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Unpublished"}}}},"/api/public/workspaces/{slug}":{"get":{"tags":["Public Documents"],"operationId":"list_workspace_public_documents","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Public documents for workspace","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/PublicDocumentSummary"}}}}}}}},"/api/public/workspaces/{slug}/{id}":{"get":{"tags":["Public Documents"],"operationId":"get_public_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Document"}}}}}}},"/api/public/workspaces/{slug}/{id}/content":{"get":{"tags":["Public Documents"],"operationId":"get_public_content_by_workspace_and_id","parameters":[{"name":"slug","in":"path","description":"Workspace slug","required":true,"schema":{"type":"string"}},{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Document content"}}}},"/api/shares":{"post":{"tags":["Sharing"],"operationId":"create_share","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareRequest"}}},"required":true},"responses":{"200":{"description":"Share link created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareResponse"}}}}}}},"/api/shares/active":{"get":{"tags":["Sharing"],"operationId":"list_active_shares","responses":{"200":{"description":"Active shares","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ActiveShareItem"}}}}}}}},"/api/shares/applicable":{"get":{"tags":["Sharing"],"operationId":"list_applicable_shares","parameters":[{"name":"doc_id","in":"query","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"Shares that include the document","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ApplicableShareItem"}}}}}}}},"/api/shares/browse":{"get":{"tags":["Sharing"],"operationId":"browse_share","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Share tree","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareBrowseResponse"}}}}}}},"/api/shares/documents/{id}":{"get":{"tags":["Sharing"],"operationId":"list_document_shares","parameters":[{"name":"id","in":"path","description":"Document ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"OK","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareItem"}}}}}}}},"/api/shares/folders/{token}/materialize":{"post":{"tags":["Sharing"],"operationId":"materialize_folder_share","parameters":[{"name":"token","in":"path","description":"Folder share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Created doc shares","content":{"application/json":{"schema":{"$ref":"#/components/schemas/MaterializeResponse"}}}}}}},"/api/shares/mounts":{"get":{"tags":["Sharing"],"operationId":"list_share_mounts","responses":{"200":{"description":"Share mounts","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"post":{"tags":["Sharing"],"operationId":"create_share_mount","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateShareMountRequest"}}},"required":true},"responses":{"200":{"description":"Saved share mount","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareMountItem"}}}}}}},"/api/shares/mounts/{id}":{"delete":{"tags":["Sharing"],"operationId":"delete_share_mount","parameters":[{"name":"id","in":"path","description":"Share mount ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":"Share mount removed"}}}},"/api/shares/validate":{"get":{"tags":["Sharing"],"operationId":"validate_share_token","parameters":[{"name":"token","in":"query","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Document info","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ShareDocumentResponse"}}}}}}},"/api/shares/{token}":{"delete":{"tags":["Sharing"],"operationId":"delete_share","parameters":[{"name":"token","in":"path","description":"Share token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":"Share link deleted"}}}},"/api/tags":{"get":{"tags":["Tags"],"operationId":"list_tags","parameters":[{"name":"q","in":"query","description":"Filter contains","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/TagItem"}}}}}}}},"/api/workspace-invitations/{token}/accept":{"post":{"tags":["Workspaces"],"operationId":"accept_invitation","parameters":[{"name":"token","in":"path","description":"Invitation token","required":true,"schema":{"type":"string"}}],"responses":{"204":{"description":""}}}},"/api/workspaces":{"get":{"tags":["Workspaces"],"operationId":"list_workspaces","responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_workspace","requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}}},"/api/workspaces/{id}":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_detail","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"put":{"tags":["Workspaces"],"operationId":"update_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceResponse"}}}}}},"delete":{"tags":["Workspaces"],"operationId":"delete_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}}},"/api/workspaces/{id}/download":{"get":{"tags":["Workspaces"],"operationId":"download_workspace_archive","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"format","in":"query","description":"Download format (archive only)","required":false,"schema":{"allOf":[{"$ref":"#/components/schemas/DownloadFormat"}],"nullable":true}}],"responses":{"200":{"description":"Workspace download","content":{"application/octet-stream":{"schema":{"$ref":"#/components/schemas/DocumentDownloadBinary"}}}},"401":{"description":"Unauthorized"},"404":{"description":"Workspace not found"}}}},"/api/workspaces/{id}/invitations":{"get":{"tags":["Workspaces"],"operationId":"list_invitations","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceInvitationRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/invitations/{invitation_id}":{"delete":{"tags":["Workspaces"],"operationId":"revoke_invitation","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"invitation_id","in":"path","description":"Invitation ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceInvitationResponse"}}}}}}},"/api/workspaces/{id}/members":{"get":{"tags":["Workspaces"],"operationId":"list_members","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}}},"/api/workspaces/{id}/members/{user_id}":{"delete":{"tags":["Workspaces"],"operationId":"remove_member","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_member_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"user_id","in":"path","description":"Target user ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateMemberRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceMemberResponse"}}}}}}},"/api/workspaces/{id}/permissions":{"get":{"tags":["Workspaces"],"operationId":"get_workspace_permissions","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspacePermissionsResponse"}}}}}}},"/api/workspaces/{id}/roles":{"get":{"tags":["Workspaces"],"operationId":"list_roles","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"post":{"tags":["Workspaces"],"operationId":"create_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CreateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/roles/{role_id}":{"delete":{"tags":["Workspaces"],"operationId":"delete_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"204":{"description":""}}},"patch":{"tags":["Workspaces"],"operationId":"update_role","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}},{"name":"role_id","in":"path","description":"Role ID","required":true,"schema":{"type":"string","format":"uuid"}}],"requestBody":{"content":{"application/json":{"schema":{"$ref":"#/components/schemas/UpdateWorkspaceRoleRequest"}}},"required":true},"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WorkspaceRoleResponse"}}}}}}},"/api/workspaces/{id}/switch":{"post":{"tags":["Workspaces"],"operationId":"switch_workspace","parameters":[{"name":"id","in":"path","description":"Workspace ID","required":true,"schema":{"type":"string","format":"uuid"}}],"responses":{"200":{"description":"","content":{"application/json":{"schema":{"$ref":"#/components/schemas/SwitchWorkspaceResponse"}}}}}}},"/api/yjs/{id}":{"get":{"tags":["Realtime"],"operationId":"axum_ws_entry","parameters":[{"name":"id","in":"path","description":"Document ID (UUID)","required":true,"schema":{"type":"string"}},{"name":"token","in":"query","description":"JWT or share token","required":false,"schema":{"type":"string","nullable":true}},{"name":"Authorization","in":"header","description":"Bearer token (JWT or share token)","required":false,"schema":{"type":"string","nullable":true}}],"responses":{"101":{"description":"Switching Protocols (WebSocket upgrade)"},"401":{"description":"Unauthorized"}}}}},"components":{"schemas":{"ActiveShareItem":{"type":"object","required":["id","token","permission","created_at","document_id","document_title","document_type","url"],"properties":{"created_at":{"type":"string","format":"date-time"},"document_id":{"type":"string","format":"uuid"},"document_title":{"type":"string"},"document_type":{"type":"string","description":"'document' or 'folder'"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"token":{"type":"string"},"url":{"type":"string"}}},"AddPatternsRequest":{"type":"object","required":["patterns"],"properties":{"patterns":{"type":"array","items":{"type":"string"}}}},"ApiTokenCreateRequest":{"type":"object","properties":{"name":{"type":"string","example":"Deploy token","nullable":true}}},"ApiTokenCreateResponse":{"type":"object","required":["id","name","created_at","token"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"token":{"type":"string"}}},"ApiTokenItem":{"type":"object","required":["id","name","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"last_used_at":{"type":"string","format":"date-time","nullable":true},"name":{"type":"string"},"revoked_at":{"type":"string","format":"date-time","nullable":true}}},"ApplicableShareItem":{"type":"object","required":["token","permission","scope","excluded"],"properties":{"excluded":{"type":"boolean"},"permission":{"type":"string"},"scope":{"type":"string","description":"'document' or 'folder'"},"token":{"type":"string"}}},"AuthProviderInfoResponse":{"type":"object","required":["id","requires_state","client_ids"],"properties":{"authorization_url":{"type":"string","nullable":true},"client_ids":{"type":"array","items":{"type":"string"}},"id":{"type":"string"},"name":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"requires_state":{"type":"boolean"},"scopes":{"type":"array","items":{"type":"string"}}}},"AuthProvidersResponse":{"type":"object","required":["providers"],"properties":{"providers":{"type":"array","items":{"$ref":"#/components/schemas/AuthProviderInfoResponse"}}}},"BacklinkInfo":{"type":"object","required":["document_id","title","document_type","link_type","link_count"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_count":{"type":"integer","format":"int64"},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"title":{"type":"string"}}},"BacklinksResponse":{"type":"object","required":["backlinks","total_count"],"properties":{"backlinks":{"type":"array","items":{"$ref":"#/components/schemas/BacklinkInfo"}},"total_count":{"type":"integer","minimum":0}}},"CheckIgnoredRequest":{"type":"object","required":["path"],"properties":{"path":{"type":"string"}}},"CreateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string","nullable":true},"type":{"type":"string","nullable":true}}},"CreateGitConfigRequest":{"type":"object","required":["repository_url","auth_type","auth_data"],"properties":{"auth_data":{},"auth_type":{"type":"string"},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string"}}},"CreateRecordBody":{"type":"object","required":["data"],"properties":{"data":{}}},"CreateShareMountRequest":{"type":"object","required":["token"],"properties":{"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"token":{"type":"string"}}},"CreateShareRequest":{"type":"object","required":["document_id"],"properties":{"document_id":{"type":"string","format":"uuid"},"expires_at":{"type":"string","format":"date-time","nullable":true},"permission":{"type":"string","nullable":true}}},"CreateShareResponse":{"type":"object","required":["token","url"],"properties":{"token":{"type":"string"},"url":{"type":"string"}}},"CreateWorkspaceInvitationRequest":{"type":"object","required":["email","role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"CreateWorkspaceRequest":{"type":"object","required":["name"],"properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string"}}},"CreateWorkspaceRoleRequest":{"type":"object","required":["name","base_role"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"Document":{"type":"object","required":["id","owner_id","workspace_id","title","type","created_at","updated_at","slug","desired_path"],"properties":{"archived_at":{"type":"string","format":"date-time","nullable":true},"archived_by":{"type":"string","format":"uuid","nullable":true},"archived_parent_id":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"created_by_plugin":{"type":"string","nullable":true},"desired_path":{"type":"string"},"id":{"type":"string","format":"uuid"},"owner_id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"path":{"type":"string","nullable":true},"slug":{"type":"string"},"title":{"type":"string"},"type":{"type":"string"},"updated_at":{"type":"string","format":"date-time"},"workspace_id":{"type":"string","format":"uuid"}}},"DocumentArchiveBinary":{"type":"string","format":"binary"},"DocumentDownloadBinary":{"type":"string","format":"binary"},"DocumentListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/Document"}}}},"DocumentPatchOperationRequest":{"oneOf":[{"type":"object","required":["offset","text","op"],"properties":{"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["insert"]},"text":{"type":"string"}}},{"type":"object","required":["offset","length","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["delete"]}}},{"type":"object","required":["offset","length","text","op"],"properties":{"length":{"type":"integer","minimum":0},"offset":{"type":"integer","minimum":0},"op":{"type":"string","enum":["replace"]},"text":{"type":"string"}}}],"discriminator":{"propertyName":"op"}},"DownloadDocumentQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"},"token":{"type":"string","nullable":true}}},"DownloadFormat":{"type":"string","enum":["archive","markdown","html","html5","pdf","docx","latex","beamer","context","man","mediawiki","dokuwiki","textile","org","texinfo","opml","docbook","opendocument","odt","rtf","epub","epub3","fb2","asciidoc","icml","slidy","slideous","dzslides","revealjs","s5","json","plain","commonmark","commonmark_x","markdown_strict","markdown_phpextra","markdown_github","rst","native","haddock"]},"DownloadWorkspaceQuery":{"type":"object","properties":{"format":{"$ref":"#/components/schemas/DownloadFormat"}}},"DuplicateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"ExecBody":{"type":"object","properties":{"payload":{"nullable":true}}},"ExecResultResponse":{"type":"object","required":["ok","effects"],"properties":{"data":{"nullable":true},"effects":{"type":"array","items":{}},"error":{"nullable":true},"ok":{"type":"boolean"}}},"GitChangeItem":{"type":"object","required":["path","status"],"properties":{"path":{"type":"string"},"status":{"type":"string"}}},"GitChangesResponse":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/GitChangeItem"}}}},"GitCommitItem":{"type":"object","required":["hash","message","author_name","author_email","time"],"properties":{"author_email":{"type":"string"},"author_name":{"type":"string"},"hash":{"type":"string"},"message":{"type":"string"},"time":{"type":"string","format":"date-time"}}},"GitConfigResponse":{"type":"object","required":["id","repository_url","branch_name","auth_type","auto_sync","created_at","updated_at"],"properties":{"auth_type":{"type":"string"},"auto_sync":{"type":"boolean"},"branch_name":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"remote_check":{"allOf":[{"$ref":"#/components/schemas/GitRemoteCheckResponse"}],"nullable":true},"repository_url":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"GitHistoryResponse":{"type":"object","required":["commits"],"properties":{"commits":{"type":"array","items":{"$ref":"#/components/schemas/GitCommitItem"}}}},"GitImportResponse":{"type":"object","required":["success","message","files_changed","docs_created","attachments_created"],"properties":{"attachments_created":{"type":"integer","format":"int32"},"commit_hash":{"type":"string","nullable":true},"docs_created":{"type":"integer","format":"int32"},"files_changed":{"type":"integer","format":"int32"},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullConflictItem":{"type":"object","required":["path","is_binary"],"properties":{"base":{"type":"string","nullable":true},"document_id":{"type":"string","format":"uuid","nullable":true},"is_binary":{"type":"boolean"},"ours":{"type":"string","nullable":true},"path":{"type":"string"},"theirs":{"type":"string","nullable":true}}},"GitPullRequest":{"type":"object","properties":{"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"},"nullable":true}}},"GitPullResolution":{"type":"object","required":["path","choice"],"properties":{"choice":{"type":"string"},"content":{"type":"string","nullable":true},"path":{"type":"string"}}},"GitPullResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"},"nullable":true},"files_changed":{"type":"integer","format":"int32"},"git_status":{"allOf":[{"$ref":"#/components/schemas/GitStatus"}],"nullable":true},"message":{"type":"string"},"success":{"type":"boolean"}}},"GitPullSessionResponse":{"type":"object","required":["session_id","status","conflicts","resolutions"],"properties":{"conflicts":{"type":"array","items":{"$ref":"#/components/schemas/GitPullConflictItem"}},"message":{"type":"string","nullable":true},"resolutions":{"type":"array","items":{"$ref":"#/components/schemas/GitPullResolution"}},"session_id":{"type":"string","format":"uuid"},"status":{"type":"string"}}},"GitRemoteCheckResponse":{"type":"object","required":["ok","message"],"properties":{"message":{"type":"string"},"ok":{"type":"boolean"},"reason":{"type":"string","nullable":true}}},"GitStatus":{"type":"object","required":["repository_initialized","has_remote","uncommitted_changes","untracked_files","sync_enabled"],"properties":{"current_branch":{"type":"string","nullable":true},"has_remote":{"type":"boolean"},"last_sync":{"type":"string","format":"date-time","nullable":true},"last_sync_commit_hash":{"type":"string","nullable":true},"last_sync_message":{"type":"string","nullable":true},"last_sync_status":{"type":"string","nullable":true},"repository_initialized":{"type":"boolean"},"sync_enabled":{"type":"boolean"},"uncommitted_changes":{"type":"integer","format":"int32","minimum":0},"untracked_files":{"type":"integer","format":"int32","minimum":0}}},"GitSyncRequest":{"type":"object","properties":{"force":{"type":"boolean","nullable":true},"full_scan":{"type":"boolean","nullable":true},"message":{"type":"string","nullable":true},"skip_push":{"type":"boolean","nullable":true}}},"GitSyncResponse":{"type":"object","required":["success","message","files_changed"],"properties":{"commit_hash":{"type":"string","nullable":true},"files_changed":{"type":"integer","format":"int32","minimum":0},"message":{"type":"string"},"success":{"type":"boolean"}}},"HealthResp":{"type":"object","required":["status"],"properties":{"status":{"type":"string"}}},"InstallFromUrlBody":{"type":"object","required":["url"],"properties":{"token":{"type":"string","nullable":true},"url":{"type":"string"}}},"InstallResponse":{"type":"object","required":["id","version"],"properties":{"id":{"type":"string"},"version":{"type":"string"}}},"KvValueBody":{"type":"object","required":["value"],"properties":{"value":{}}},"KvValueResponse":{"type":"object","required":["value"],"properties":{"value":{}}},"LoginRequest":{"type":"object","required":["email","password"],"properties":{"email":{"type":"string"},"password":{"type":"string"},"remember_me":{"type":"boolean"}}},"LoginResponse":{"type":"object","required":["access_token","user"],"properties":{"access_token":{"type":"string"},"user":{"$ref":"#/components/schemas/UserResponse"}}},"ManifestItem":{"type":"object","required":["id","version","scope","mounts","frontend","permissions","config","ui"],"properties":{"author":{"type":"string","nullable":true},"config":{},"frontend":{},"id":{"type":"string"},"mounts":{"type":"array","items":{"type":"string"}},"name":{"type":"string","nullable":true},"permissions":{"type":"array","items":{"type":"string"}},"repository":{"type":"string","nullable":true},"scope":{"type":"string"},"ui":{},"version":{"type":"string"}}},"MaterializeResponse":{"type":"object","required":["created"],"properties":{"created":{"type":"integer","format":"int64"}}},"OAuthLoginRequest":{"type":"object","properties":{"code":{"type":"string","nullable":true},"credential":{"type":"string","nullable":true},"redirect_uri":{"type":"string","nullable":true},"remember_me":{"type":"boolean"},"state":{"type":"string","nullable":true}}},"OAuthStateResponse":{"type":"object","required":["state"],"properties":{"state":{"type":"string"}}},"OutgoingLink":{"type":"object","required":["document_id","title","document_type","link_type"],"properties":{"document_id":{"type":"string"},"document_type":{"type":"string"},"file_path":{"type":"string","nullable":true},"link_text":{"type":"string","nullable":true},"link_type":{"type":"string"},"position_end":{"type":"integer","format":"int32","nullable":true},"position_start":{"type":"integer","format":"int32","nullable":true},"title":{"type":"string"}}},"OutgoingLinksResponse":{"type":"object","required":["links","total_count"],"properties":{"links":{"type":"array","items":{"$ref":"#/components/schemas/OutgoingLink"}},"total_count":{"type":"integer","minimum":0}}},"PatchDocumentContentRequest":{"type":"object","required":["operations"],"properties":{"operations":{"type":"array","items":{"$ref":"#/components/schemas/DocumentPatchOperationRequest"}}}},"PermissionOverridePayload":{"type":"object","required":["permission","allowed"],"properties":{"allowed":{"type":"boolean"},"permission":{"type":"string"}}},"PlaceholderItemPayload":{"type":"object","required":["kind","id","code"],"properties":{"code":{"type":"string"},"id":{"type":"string"},"kind":{"type":"string"}}},"PublicDocumentSummary":{"type":"object","required":["id","title","updated_at","published_at"],"properties":{"id":{"type":"string","format":"uuid"},"published_at":{"type":"string","format":"date-time"},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"PublishResponse":{"type":"object","required":["slug","public_url"],"properties":{"public_url":{"type":"string"},"slug":{"type":"string"}}},"RecordsResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{}}}},"RefreshResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"RegisterRequest":{"type":"object","required":["email","name","password"],"properties":{"email":{"type":"string"},"name":{"type":"string"},"password":{"type":"string"}}},"RenderManyRequest":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderRequest"}}}},"RenderManyResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/RenderResponseBody"}}}},"RenderOptionsPayload":{"type":"object","properties":{"absolute_attachments":{"type":"boolean","default":null,"nullable":true},"base_origin":{"type":"string","default":null,"nullable":true},"doc_id":{"type":"string","format":"uuid","default":null,"nullable":true},"features":{"type":"array","items":{"type":"string"},"default":null,"nullable":true},"flavor":{"type":"string","default":null,"nullable":true},"hardbreaks":{"type":"boolean","default":null,"nullable":true},"sanitize":{"type":"boolean","default":null,"nullable":true},"theme":{"type":"string","default":null,"nullable":true},"token":{"type":"string","default":null,"nullable":true}}},"RenderRequest":{"type":"object","required":["text"],"properties":{"options":{"$ref":"#/components/schemas/RenderOptionsPayload"},"text":{"type":"string"}}},"RenderResponseBody":{"type":"object","required":["html","hash"],"properties":{"hash":{"type":"string"},"html":{"type":"string"},"placeholders":{"type":"array","items":{"$ref":"#/components/schemas/PlaceholderItemPayload"}}}},"SearchResult":{"type":"object","required":["id","title","document_type","updated_at"],"properties":{"document_type":{"type":"string"},"id":{"type":"string","format":"uuid"},"path":{"type":"string","nullable":true},"title":{"type":"string"},"updated_at":{"type":"string","format":"date-time"}}},"SessionResponse":{"type":"object","required":["id","workspace_id","remember_me","created_at","last_seen_at","expires_at","current"],"properties":{"created_at":{"type":"string","format":"date-time"},"current":{"type":"boolean"},"expires_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"ip_address":{"type":"string","nullable":true},"last_seen_at":{"type":"string","format":"date-time"},"remember_me":{"type":"boolean"},"user_agent":{"type":"string","nullable":true},"workspace_id":{"type":"string","format":"uuid"}}},"ShareBrowseResponse":{"type":"object","required":["tree"],"properties":{"tree":{"type":"array","items":{"$ref":"#/components/schemas/ShareBrowseTreeItem"}}}},"ShareBrowseTreeItem":{"type":"object","required":["id","title","type","created_at","updated_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_id":{"type":"string","format":"uuid","nullable":true},"title":{"type":"string"},"type":{"type":"string","example":"document"},"updated_at":{"type":"string","format":"date-time"}}},"ShareDocumentResponse":{"type":"object","required":["id","title","permission"],"properties":{"content":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"permission":{"type":"string"},"title":{"type":"string"}}},"ShareItem":{"type":"object","required":["id","token","permission","url","scope"],"properties":{"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"parent_share_id":{"type":"string","format":"uuid","description":"If present, this document share was materialized from a folder share","nullable":true},"permission":{"type":"string"},"scope":{"type":"string","description":"document | folder"},"token":{"type":"string"},"url":{"type":"string"}}},"ShareMountItem":{"type":"object","required":["id","token","target_document_id","target_document_type","target_title","permission","created_at"],"properties":{"created_at":{"type":"string","format":"date-time"},"id":{"type":"string","format":"uuid"},"parent_folder_id":{"type":"string","format":"uuid","nullable":true},"permission":{"type":"string"},"target_document_id":{"type":"string","format":"uuid"},"target_document_type":{"type":"string"},"target_title":{"type":"string"},"token":{"type":"string"}}},"SnapshotDiffBaseParam":{"type":"string","enum":["auto","current","previous"]},"SnapshotDiffKind":{"type":"string","enum":["current","snapshot"]},"SnapshotDiffResponse":{"type":"object","required":["base","target","diff"],"properties":{"base":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"},"diff":{"$ref":"#/components/schemas/TextDiffResult"},"target":{"$ref":"#/components/schemas/SnapshotDiffSideResponse"}}},"SnapshotDiffSideResponse":{"type":"object","required":["kind","markdown"],"properties":{"kind":{"$ref":"#/components/schemas/SnapshotDiffKind"},"markdown":{"type":"string"},"snapshot":{"allOf":[{"$ref":"#/components/schemas/SnapshotSummary"}],"nullable":true}}},"SnapshotListResponse":{"type":"object","required":["items"],"properties":{"items":{"type":"array","items":{"$ref":"#/components/schemas/SnapshotSummary"}}}},"SnapshotRestoreResponse":{"type":"object","required":["snapshot"],"properties":{"snapshot":{"$ref":"#/components/schemas/SnapshotSummary"}}},"SnapshotSummary":{"type":"object","required":["id","document_id","label","kind","created_at","byte_size","content_hash"],"properties":{"byte_size":{"type":"integer","format":"int64"},"content_hash":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"created_by":{"type":"string","format":"uuid","nullable":true},"document_id":{"type":"string","format":"uuid"},"id":{"type":"string","format":"uuid"},"kind":{"type":"string"},"label":{"type":"string"},"notes":{"type":"string","nullable":true}}},"SwitchWorkspaceResponse":{"type":"object","required":["access_token"],"properties":{"access_token":{"type":"string"}}},"TagItem":{"type":"object","required":["name","count"],"properties":{"count":{"type":"integer","format":"int64"},"name":{"type":"string"}}},"TextDiffLine":{"type":"object","required":["line_type","content"],"properties":{"content":{"type":"string"},"line_type":{"$ref":"#/components/schemas/TextDiffLineType"},"new_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0},"old_line_number":{"type":"integer","format":"int32","nullable":true,"minimum":0}}},"TextDiffLineType":{"type":"string","enum":["added","deleted","context"]},"TextDiffResult":{"type":"object","required":["file_path","diff_lines"],"properties":{"diff_lines":{"type":"array","items":{"$ref":"#/components/schemas/TextDiffLine"}},"file_path":{"type":"string"},"new_content":{"type":"string","nullable":true},"old_content":{"type":"string","nullable":true}}},"UninstallBody":{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}},"UpdateDocumentContentRequest":{"type":"object","required":["content"],"properties":{"content":{"type":"string"}}},"UpdateDocumentRequest":{"type":"object","properties":{"parent_id":{"type":"string","nullable":true},"title":{"type":"string","nullable":true}}},"UpdateGitConfigRequest":{"type":"object","properties":{"auth_data":{"nullable":true},"auth_type":{"type":"string","nullable":true},"auto_sync":{"type":"boolean","nullable":true},"branch_name":{"type":"string","nullable":true},"repository_url":{"type":"string","nullable":true}}},"UpdateMemberRoleRequest":{"type":"object","required":["role_kind"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"UpdateRecordBody":{"type":"object","required":["patch"],"properties":{"patch":{}}},"UpdateUserShortcutRequest":{"type":"object","properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true}}},"UpdateWorkspaceRequest":{"type":"object","properties":{"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"name":{"type":"string","nullable":true}}},"UpdateWorkspaceRoleRequest":{"type":"object","properties":{"base_role":{"type":"string","nullable":true},"description":{"type":"string","nullable":true},"name":{"type":"string","nullable":true},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"},"nullable":true},"priority":{"type":"integer","format":"int32","nullable":true}}},"UploadFileMultipart":{"type":"object","required":["file","document_id"],"properties":{"document_id":{"type":"string","format":"uuid","description":"Target document ID"},"file":{"type":"string","format":"binary","description":"File to upload"}}},"UploadFileResponse":{"type":"object","required":["id","url","filename","size"],"properties":{"content_type":{"type":"string","nullable":true},"filename":{"type":"string"},"id":{"type":"string","format":"uuid"},"size":{"type":"integer","format":"int64"},"url":{"type":"string"}}},"UserResponse":{"type":"object","required":["id","email","name","workspaces"],"properties":{"active_workspace":{"allOf":[{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}],"nullable":true},"active_workspace_id":{"type":"string","format":"uuid","nullable":true},"active_workspace_permissions":{"type":"array","items":{"type":"string"}},"email":{"type":"string"},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"workspaces":{"type":"array","items":{"$ref":"#/components/schemas/WorkspaceMembershipResponse"}}}},"UserShortcutResponse":{"type":"object","required":["bindings"],"properties":{"bindings":{"type":"object"},"leader_key":{"type":"string","example":"","nullable":true},"updated_at":{"type":"string","format":"date-time","nullable":true}}},"WorkspaceInvitationResponse":{"type":"object","required":["id","workspace_id","email","role_kind","invited_by","token","created_at"],"properties":{"accepted_at":{"type":"string","format":"date-time","nullable":true},"accepted_by":{"type":"string","format":"uuid","nullable":true},"created_at":{"type":"string","format":"date-time"},"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"expires_at":{"type":"string","format":"date-time","nullable":true},"id":{"type":"string","format":"uuid"},"invited_by":{"type":"string","format":"uuid"},"revoked_at":{"type":"string","format":"date-time","nullable":true},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"token":{"type":"string"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMemberResponse":{"type":"object","required":["workspace_id","user_id","email","name","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"email":{"type":"string"},"is_default":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"system_role":{"type":"string","nullable":true},"user_id":{"type":"string","format":"uuid"},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceMembershipResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspacePermissionsResponse":{"type":"object","required":["workspace_id","permissions"],"properties":{"permissions":{"type":"array","items":{"type":"string"}},"workspace_id":{"type":"string","format":"uuid"}}},"WorkspaceResponse":{"type":"object","required":["id","name","slug","is_personal","role_kind","is_default"],"properties":{"custom_role_id":{"type":"string","format":"uuid","nullable":true},"description":{"type":"string","nullable":true},"icon":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"is_default":{"type":"boolean"},"is_personal":{"type":"boolean"},"name":{"type":"string"},"role_kind":{"type":"string"},"slug":{"type":"string"},"system_role":{"type":"string","nullable":true}}},"WorkspaceRoleResponse":{"type":"object","required":["id","workspace_id","name","base_role","priority","overrides"],"properties":{"base_role":{"type":"string"},"description":{"type":"string","nullable":true},"id":{"type":"string","format":"uuid"},"name":{"type":"string"},"overrides":{"type":"array","items":{"$ref":"#/components/schemas/PermissionOverridePayload"}},"priority":{"type":"integer","format":"int32"},"workspace_id":{"type":"string","format":"uuid"}}}}},"tags":[{"name":"Auth","description":"Authentication"},{"name":"Documents","description":"Documents management"},{"name":"Files","description":"File management"},{"name":"Sharing","description":"Document sharing"},{"name":"Public Documents","description":"Public pages"},{"name":"Realtime","description":"Yjs WebSocket endpoint (/yjs/:id)"},{"name":"Git","description":"Git integration"},{"name":"Markdown","description":"Markdown rendering"},{"name":"Plugins","description":"Plugins management & data APIs"},{"name":"Health","description":"System health checks"}]} diff --git a/api/src/application/dto/git.rs b/api/src/application/dto/git.rs index b35a0f32..6b46112e 100644 --- a/api/src/application/dto/git.rs +++ b/api/src/application/dto/git.rs @@ -86,6 +86,15 @@ pub struct GitSyncOutcome { pub message: String, } +#[derive(Debug, Clone)] +pub struct GitImportOutcome { + pub files_changed: u32, + pub commit_hash: Option, + pub docs_created: u32, + pub attachments_created: u32, + pub message: String, +} + #[derive(Debug, Clone)] pub struct GitignoreUpdateDto { pub added: usize, diff --git a/api/src/application/ports/git_workspace.rs b/api/src/application/ports/git_workspace.rs index 3b8e8705..8a9ea1ac 100644 --- a/api/src/application/ports/git_workspace.rs +++ b/api/src/application/ports/git_workspace.rs @@ -3,8 +3,8 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitPullRequestDto, GitPullResultDto, GitRemoteCheckDto, - GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitImportOutcome, GitPullRequestDto, GitPullResultDto, + GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; use crate::application::ports::git_repository::UserGitCfg; @@ -32,9 +32,16 @@ pub trait GitWorkspacePort: Send + Sync { req: &GitSyncRequestDto, cfg: Option<&UserGitCfg>, ) -> anyhow::Result; + async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result; async fn pull( &self, workspace_id: Uuid, + actor_id: Uuid, req: &GitPullRequestDto, cfg: &UserGitCfg, ) -> anyhow::Result; diff --git a/api/src/application/services/git.rs b/api/src/application/services/git.rs index 41bf1339..0b8b3983 100644 --- a/api/src/application/services/git.rs +++ b/api/src/application/services/git.rs @@ -239,6 +239,41 @@ impl GitService { .map_err(ServiceError::from) } + pub async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + input: &UpsertGitConfigInput, + ) -> Result { + // Save configuration first + let _ = self.upsert_config(workspace_id, input).await?; + let cfg = self + .repo + .load_user_git_cfg(workspace_id) + .await + .map_err(ServiceError::from)? + .ok_or(ServiceError::BadRequest("git_not_configured"))?; + + self.workspace + .ensure_repository(workspace_id, &cfg.branch_name) + .await + .map_err(ServiceError::from)?; + + self.workspace + .import_repository(workspace_id, actor_id, &cfg) + .await + .map_err(|err| { + let msg = err.to_string().to_lowercase(); + if msg.contains("git_http_auth_redirect") || msg.contains("too many redirects") { + ServiceError::BadRequest("git_auth_redirect") + } else if msg.contains("git_http_not_found") || msg.contains("status code: 404") { + ServiceError::BadRequest("git_repo_not_found") + } else { + ServiceError::from(err) + } + }) + } + pub async fn ignore_document( &self, workspace_id: Uuid, diff --git a/api/src/application/services/git_rebuild.rs b/api/src/application/services/git_rebuild.rs index c915b3c1..3e58fdc2 100644 --- a/api/src/application/services/git_rebuild.rs +++ b/api/src/application/services/git_rebuild.rs @@ -341,6 +341,21 @@ mod tests { } } + async fn import_repository( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _cfg: &crate::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + Ok(crate::application::dto::git::GitImportOutcome { + files_changed: 0, + commit_hash: None, + docs_created: 0, + attachments_created: 0, + message: "not implemented".to_string(), + }) + } + async fn check_remote( &self, _workspace_id: Uuid, diff --git a/api/src/bin/export-openapi.rs b/api/src/bin/export-openapi.rs index d01e2891..581716f4 100644 --- a/api/src/bin/export-openapi.rs +++ b/api/src/bin/export-openapi.rs @@ -76,6 +76,7 @@ use utoipa::OpenApi; git::get_working_diff, git::get_commit_diff, git::sync_now, + git::import_repository, git::pull_repository, git::start_pull_session, git::get_pull_session, @@ -188,6 +189,7 @@ use utoipa::OpenApi; git::GitSyncResponse, git::GitPullRequest, git::GitPullResponse, + git::GitImportResponse, git::GitPullSessionResponse, git::GitPullResolution, git::GitPullConflictItem, diff --git a/api/src/bin/refmd.rs b/api/src/bin/refmd.rs index a83a6ece..5c182fd8 100644 --- a/api/src/bin/refmd.rs +++ b/api/src/bin/refmd.rs @@ -601,6 +601,15 @@ impl GitWorkspacePort for CliGitWorkspace { bail!("pull not supported in refmd CLI"); } + async fn import_repository( + &self, + _workspace_id: Uuid, + _actor_id: Uuid, + _cfg: &api::application::ports::git_repository::UserGitCfg, + ) -> anyhow::Result { + bail!("import not supported in refmd CLI"); + } + async fn head_commit(&self, workspace_id: Uuid) -> anyhow::Result>> { Ok(self .latest_commit_meta(workspace_id) diff --git a/api/src/infrastructure/git/workspace.rs b/api/src/infrastructure/git/workspace.rs index 646ae850..0ffb26b2 100644 --- a/api/src/infrastructure/git/workspace.rs +++ b/api/src/infrastructure/git/workspace.rs @@ -19,9 +19,10 @@ use uuid::Uuid; use crate::application::dto::diff::TextDiffResult; use crate::application::dto::git::{ - GitChangeItem, GitCommitInfo, GitPullConflictItemDto, GitPullRequestDto, GitPullResultDto, - GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, + GitChangeItem, GitCommitInfo, GitImportOutcome, GitPullConflictItemDto, GitPullRequestDto, + GitPullResultDto, GitRemoteCheckDto, GitSyncOutcome, GitSyncRequestDto, GitWorkspaceStatus, }; +use crate::application::ports::document_repository::DocumentRepository; use crate::application::ports::git_repository::UserGitCfg; use crate::application::ports::git_storage::{ BlobKey, CommitMeta, GitStorage, decode_commit_id, encode_commit_id, @@ -30,9 +31,11 @@ use crate::application::ports::git_workspace::GitWorkspacePort; use crate::application::ports::realtime_port::RealtimeEngine; use crate::application::ports::storage_port::StorageResolverPort; use crate::application::services::diff::text_diff::compute_text_diff; -use crate::application::services::realtime::snapshot::SnapshotService; +use crate::application::services::realtime::snapshot::{SnapshotService, snapshot_from_markdown}; use crate::infrastructure::db::PgPool; +use crate::infrastructure::db::repositories::document_repository_sqlx::SqlxDocumentRepository; use tokio::fs as async_fs; +use sha2::{Digest as ShaDigest, Sha256}; pub struct GitWorkspaceService { pool: PgPool, @@ -985,6 +988,243 @@ impl GitWorkspaceService { Ok(changed) } + async fn ensure_folder( + &self, + repo: &SqlxDocumentRepository, + workspace_id: Uuid, + actor_id: Uuid, + folder_path: &str, + cache: &mut HashMap, + ) -> anyhow::Result> { + let trimmed = folder_path.trim_matches('/'); + if trimmed.is_empty() { + return Ok(None); + } + + let mut current_parent: Option = None; + let mut accumulated = String::new(); + for segment in trimmed.split('/') { + if !accumulated.is_empty() { + accumulated.push('/'); + } + accumulated.push_str(segment); + + if let Some(id) = cache.get(&accumulated) { + current_parent = Some(*id); + continue; + } + + if let Some(existing) = sqlx::query_scalar::<_, Uuid>( + "SELECT id FROM documents WHERE workspace_id = $1 AND desired_path = $2 AND type = 'folder' LIMIT 1", + ) + .bind(workspace_id) + .bind(&accumulated) + .fetch_optional(&self.pool) + .await? + { + cache.insert(accumulated.clone(), existing); + current_parent = Some(existing); + continue; + } + + let title = if segment.trim().is_empty() { + "folder" + } else { + segment + }; + let folder = repo + .create_for_user( + workspace_id, + actor_id, + title, + current_parent, + "folder", + None, + ) + .await?; + + let desired_path = accumulated.clone(); + let normalized = format!("{}/{}", workspace_id, desired_path); + let path_digest: Vec = Sha256::digest(desired_path.as_bytes()).to_vec(); + let slug = slug_from_git_path(&desired_path)?; + + sqlx::query( + r#"UPDATE documents SET + path = $3, + desired_path = $4, + path_digest = $5, + slug = $6, + parent_id = $7, + updated_at = now() + WHERE id = $1 AND workspace_id = $2"#, + ) + .bind(folder.id) + .bind(workspace_id) + .bind(&normalized) + .bind(&desired_path) + .bind(path_digest) + .bind(&slug) + .bind(current_parent) + .execute(&self.pool) + .await?; + + cache.insert(accumulated.clone(), folder.id); + current_parent = Some(folder.id); + } + + Ok(current_parent) + } + + async fn materialize_documents_from_state( + &self, + workspace_id: Uuid, + actor_id: Uuid, + state: &HashMap, + ) -> anyhow::Result<(u32, u32)> { + let repo = SqlxDocumentRepository::new(self.pool.clone()); + let mut folder_cache: HashMap = HashMap::new(); + let mut docs_created: u32 = 0; + let mut attachments_created: u32 = 0; + + let mut paths: Vec = state.keys().cloned().collect(); + paths.sort(); + + for path in paths { + let snapshot = match state.get(&path) { + Some(s) => s, + None => continue, + }; + let normalized = normalize_repo_path(path.clone()); + let parent_path = normalized + .rsplitn(2, '/') + .nth(1) + .map(|s| s.trim().trim_end_matches('/').to_string()) + .filter(|s| !s.is_empty()); + let parent_id = if let Some(ppath) = parent_path.as_ref() { + self.ensure_folder( + &repo, + workspace_id, + actor_id, + ppath, + &mut folder_cache, + ) + .await? + } else { + None + }; + + // Skip if document already exists at desired_path + if sqlx::query_scalar::<_, Option>( + "SELECT id FROM documents WHERE workspace_id = $1 AND desired_path = $2 LIMIT 1", + ) + .bind(workspace_id) + .bind(&normalized) + .fetch_optional(&self.pool) + .await? + .is_some() + { + continue; + } + + let filename = normalized + .rsplit('/') + .next() + .unwrap_or(&normalized) + .to_string(); + let title = filename + .trim_end_matches(".md") + .trim_end_matches(".markdown") + .trim_end_matches(".txt"); + + let doc = repo + .create_for_user( + workspace_id, + actor_id, + if title.is_empty() { "Document" } else { title }, + parent_id, + "document", + None, + ) + .await?; + + // Force repo path to match Git path inside the same transaction + let trimmed = normalized.trim_start_matches('/'); + let desired_path = trimmed.to_string(); + let owner_prefix = workspace_id.to_string(); + let normalized_path = format!("{owner_prefix}/{}", desired_path); + let path_digest: Vec = Sha256::digest(desired_path.as_bytes()).to_vec(); + let slug = slug_from_git_path(&desired_path)?; + let parent_path = parent_path_from_git(&desired_path); + let parent_id_for_update = if let Some(pp) = parent_path { + self.ensure_folder( + &repo, + workspace_id, + actor_id, + pp.as_str(), + &mut folder_cache, + ) + .await? + } else { + None + }; + + sqlx::query( + r#"UPDATE documents SET + path = $3, + desired_path = $4, + path_digest = $5, + slug = $6, + parent_id = $7, + updated_at = now() + WHERE id = $1 AND workspace_id = $2"#, + ) + .bind(doc.id) + .bind(workspace_id) + .bind(&normalized_path) + .bind(&desired_path) + .bind(path_digest) + .bind(&slug) + .bind(parent_id_for_update) + .execute(&self.pool) + .await?; + docs_created += 1; + + let bytes = self.snapshot_bytes(snapshot).await.unwrap_or_default(); + if snapshot.is_text { + let body = extract_markdown_body(&bytes).unwrap_or_else(|| { + std::str::from_utf8(&bytes) + .unwrap_or_default() + .to_string() + }); + let snap_bytes = snapshot_from_markdown(&body); + let _ = self + .realtime + .apply_snapshot(&doc.id.to_string(), snap_bytes.as_slice()) + .await; + let _ = self.realtime.force_persist(&doc.id.to_string()).await; + } else { + // Treat as attachment on the created document + let storage_path = format!("{}/{}", workspace_id, normalized); + let hash = snapshot.hash.clone(); + let size = bytes.len() as i64; + let _ = sqlx::query( + r#"INSERT INTO files (document_id, filename, content_type, size, storage_path, content_hash) + VALUES ($1,$2,$3,$4,$5,$6)"#, + ) + .bind(doc.id) + .bind(&filename) + .bind::>(None) + .bind(size) + .bind(&storage_path) + .bind(&hash) + .execute(&self.pool) + .await; + attachments_created += 1; + } + } + Ok((docs_created, attachments_created)) + } + /// Apply merged markdown files directly to realtime/persistence so documents reflect Pull results. async fn apply_merged_to_documents( &self, @@ -2043,6 +2283,65 @@ impl GitWorkspacePort for GitWorkspaceService { }) } + async fn import_repository( + &self, + workspace_id: Uuid, + actor_id: Uuid, + cfg: &UserGitCfg, + ) -> anyhow::Result { + let branch = if cfg.branch_name.is_empty() { + "main".to_string() + } else { + cfg.branch_name.clone() + }; + self.ensure_repository(workspace_id, &branch).await?; + + let previous_index = self + .latest_commit_meta(workspace_id) + .await? + .map(|m| m.file_hash_index) + .unwrap_or_default(); + + // Populate storage and DB with remote history; ignore auth/branch errors upstream. + let _ = self + .bootstrap_remote_history(workspace_id, cfg, branch.as_str()) + .await?; + let latest = self.ensure_latest_meta(workspace_id).await?; + let Some(latest_meta) = latest else { + return Ok(GitImportOutcome { + files_changed: 0, + commit_hash: None, + docs_created: 0, + attachments_created: 0, + message: "remote has no commits".to_string(), + }); + }; + + let state = self + .state_from_commit_meta(workspace_id, &latest_meta) + .await?; + let files_changed = self + .apply_state_to_workspace(workspace_id, &state, &previous_index) + .await?; + + // Materialize documents and attachments from imported state + let (docs_created, attachments_created) = self + .materialize_documents_from_state(workspace_id, actor_id, &state) + .await + .unwrap_or((0, 0)); + + let _ = self.apply_merged_to_documents(workspace_id, &state).await; + let _ = self.clear_dirty(workspace_id).await; + + Ok(GitImportOutcome { + files_changed, + docs_created, + attachments_created, + commit_hash: Some(encode_commit_id(&latest_meta.commit_id)), + message: "import completed".to_string(), + }) + } + async fn pull( &self, workspace_id: Uuid, @@ -3598,6 +3897,34 @@ fn repo_relative_path(path: &str) -> anyhow::Result { } } +fn slug_from_git_path(desired_path: &str) -> anyhow::Result { + let segment = desired_path + .rsplit('/') + .next() + .unwrap_or(desired_path) + .trim(); + if segment.is_empty() { + anyhow::bail!("invalid_slug_from_path"); + } + let slug = segment + .strip_suffix(".md") + .unwrap_or(segment) + .trim_matches('/'); + if slug.is_empty() { + anyhow::bail!("invalid_slug_from_path"); + } + Ok(slug.to_string()) +} + +fn parent_path_from_git(desired_path: &str) -> Option { + let mut parts = desired_path.rsplitn(2, '/'); + parts.next(); + parts + .next() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) +} + fn normalize_repo_path(path: String) -> String { let trimmed = path.trim_start_matches('/'); if trimmed.is_empty() { diff --git a/api/src/main.rs b/api/src/main.rs index c57b2c2e..69ea070b 100644 --- a/api/src/main.rs +++ b/api/src/main.rs @@ -148,6 +148,7 @@ const SESSION_CLEANUP_BATCH_SIZE: i64 = 500; api::presentation::http::git::get_working_diff, api::presentation::http::git::get_commit_diff, api::presentation::http::git::sync_now, + api::presentation::http::git::import_repository, api::presentation::http::git::start_pull_session, api::presentation::http::git::get_pull_session, api::presentation::http::git::resolve_pull_session, diff --git a/api/src/presentation/http/git.rs b/api/src/presentation/http/git.rs index ebf82a4e..cceedd72 100644 --- a/api/src/presentation/http/git.rs +++ b/api/src/presentation/http/git.rs @@ -38,6 +38,7 @@ pub fn routes(ctx: AppContext) -> Router { .route("/git/diff/working", get(get_working_diff)) .route("/git/diff/commits/:from/:to", get(get_commit_diff)) .route("/git/sync", post(sync_now)) + .route("/git/import", post(import_repository)) .route("/git/pull", post(pull_repository)) .route("/git/pull/start", post(start_pull_session)) .route("/git/pull/session/:id", get(get_pull_session)) @@ -205,6 +206,16 @@ pub struct GitPullResponse { pub git_status: Option, } +#[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] +pub struct GitImportResponse { + pub success: bool, + pub message: String, + pub files_changed: i32, + pub commit_hash: Option, + pub docs_created: i32, + pub attachments_created: i32, +} + #[derive(Debug, Serialize, Deserialize, ToSchema, Clone)] pub struct GitPullSessionResponse { pub session_id: uuid::Uuid, @@ -625,6 +636,50 @@ pub async fn sync_now( })) } +#[utoipa::path( + post, + path = "/api/git/import", + tag = "Git", + request_body = CreateGitConfigRequest, + responses((status = 200, body = GitImportResponse)) +)] +pub async fn import_repository( + State(ctx): State, + bearer: Bearer, + headers: HeaderMap, + Json(req): Json, +) -> Result, StatusCode> { + if req.repository_url.trim().is_empty() { + return Err(StatusCode::BAD_REQUEST); + } + let bearer_token = bearer.0.clone(); + let sub = validate_bearer(&ctx, bearer).await?; + let user_id = uuid::Uuid::parse_str(&sub).map_err(|_| StatusCode::UNAUTHORIZED)?; + let workspace_id = workspace_scope::resolve_active_workspace_id( + &ctx, + &headers, + Some(bearer_token.as_str()), + user_id, + ) + .await?; + workspace_scope::ensure_workspace_permission(&ctx, workspace_id, user_id, PERM_GIT_INIT) + .await?; + + let service = ctx.git_service(); + let dto = service + .import_repository(workspace_id, user_id, &UpsertGitConfigInput::from(req)) + .await + .map_err(map_git_error)?; + Ok(Json(GitImportResponse { + success: true, + message: dto.message, + files_changed: dto.files_changed as i32, + commit_hash: dto.commit_hash, + docs_created: dto.docs_created as i32, + attachments_created: dto.attachments_created as i32, + })) +} + #[utoipa::path( post, path = "/api/git/pull", diff --git a/app/src/entities/git/api/index.ts b/app/src/entities/git/api/index.ts index eb2d36cb..520eeecc 100644 --- a/app/src/entities/git/api/index.ts +++ b/app/src/entities/git/api/index.ts @@ -15,14 +15,18 @@ import { getPullSession as apiGetPullSession, resolvePullSession as apiResolvePullSession, finalizePullSession as apiFinalizePullSession, + importRepository as apiImportRepository, syncNow as apiSyncNow, } from '@/shared/api' import type { GitChangesResponse, GitHistoryResponse, + GitImportResponse, GitPullResponse, GitPullSessionResponse, GitStatus, + ImportRepositoryData, + ImportRepositoryResponse, PullRepositoryData, TextDiffResult, } from '@/shared/api' @@ -69,9 +73,17 @@ export { apiGetPullSession as getPullSession, apiResolvePullSession as resolvePullSession, apiFinalizePullSession as finalizePullSession, + apiImportRepository as importRepository, apiSyncNow as syncNow, apiIgnoreDocument as ignoreDocument, apiIgnoreFolder as ignoreFolder, } -export type { GitPullResponse, GitPullSessionResponse, PullRepositoryData } +export type { + GitImportResponse, + GitPullResponse, + GitPullSessionResponse, + ImportRepositoryData, + ImportRepositoryResponse, + PullRepositoryData, +} diff --git a/app/src/features/auth/lib/types.ts b/app/src/features/auth/lib/types.ts index da34936b..a2c159b4 100644 --- a/app/src/features/auth/lib/types.ts +++ b/app/src/features/auth/lib/types.ts @@ -2,7 +2,7 @@ import type { UserResponse } from '@/shared/api' export type AuthRedirectTarget = { to: string - search?: Record + search?: Record } export type AuthMiddlewareContext = { diff --git a/app/src/features/git-sync/ui/git-pull-dialog.tsx b/app/src/features/git-sync/ui/git-pull-dialog.tsx index 74fde50b..4e1e1202 100644 --- a/app/src/features/git-sync/ui/git-pull-dialog.tsx +++ b/app/src/features/git-sync/ui/git-pull-dialog.tsx @@ -1,5 +1,6 @@ -import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react' import { Link } from '@tanstack/react-router' +import { AlertTriangle, ExternalLink, Loader2 } from 'lucide-react' + import type { GitPullConflictItem } from '@/shared/api' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' diff --git a/app/src/shared/api/client/sdk.gen.ts b/app/src/shared/api/client/sdk.gen.ts index 15ba169d..cfe13a96 100644 --- a/app/src/shared/api/client/sdk.gen.ts +++ b/app/src/shared/api/client/sdk.gen.ts @@ -3,7 +3,7 @@ import type { CancelablePromise } from './core/CancelablePromise'; import { OpenAPI } from './core/OpenAPI'; import { request as __request } from './core/request'; -import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; +import type { LoginData, LoginResponse2, LogoutResponse, MeResponse, DeleteAccountResponse, OauthLoginData, OauthLoginResponse, OauthStateData, OauthStateResponse, ListOauthProvidersResponse, RefreshSessionResponse, RegisterData, RegisterResponse, ListSessionsResponse, RevokeSessionData, RevokeSessionResponse, ListDocumentsData, ListDocumentsResponse, CreateDocumentData, CreateDocumentResponse, SearchDocumentsData, SearchDocumentsResponse, GetDocumentData, GetDocumentResponse, DeleteDocumentData, DeleteDocumentResponse, UpdateDocumentData, UpdateDocumentResponse, ArchiveDocumentData, ArchiveDocumentResponse, GetBacklinksData, GetBacklinksResponse, GetDocumentContentData, GetDocumentContentResponse, UpdateDocumentContentData, UpdateDocumentContentResponse, PatchDocumentContentData, PatchDocumentContentResponse, DownloadDocumentData, DownloadDocumentResponse, DuplicateDocumentData, DuplicateDocumentResponse, GetOutgoingLinksData, GetOutgoingLinksResponse, ListDocumentSnapshotsData, ListDocumentSnapshotsResponse, GetDocumentSnapshotDiffData, GetDocumentSnapshotDiffResponse, DownloadDocumentSnapshotData, DownloadDocumentSnapshotResponse, RestoreDocumentSnapshotData, RestoreDocumentSnapshotResponse, UnarchiveDocumentData, UnarchiveDocumentResponse, UploadFileData, UploadFileResponse2, GetFileByNameData, GetFileByNameResponse, GetFileData, GetFileResponse, GetChangesResponse, GetConfigResponse, CreateOrUpdateConfigData, CreateOrUpdateConfigResponse, DeleteConfigResponse, DeinitRepositoryResponse, GetCommitDiffData, GetCommitDiffResponse, GetWorkingDiffResponse, CheckPathIgnoredData, CheckPathIgnoredResponse, GetGitignorePatternsResponse, AddGitignorePatternsData, AddGitignorePatternsResponse, GetHistoryResponse, IgnoreDocumentData, IgnoreDocumentResponse, IgnoreFolderData, IgnoreFolderResponse, ImportRepositoryData, ImportRepositoryResponse, InitRepositoryResponse, PullRepositoryData, PullRepositoryResponse, GetPullSessionData, GetPullSessionResponse, FinalizePullSessionData, FinalizePullSessionResponse, ResolvePullSessionData, ResolvePullSessionResponse, StartPullSessionResponse, GetStatusResponse, SyncNowData, SyncNowResponse, HealthResponse, RenderMarkdownData, RenderMarkdownResponse, RenderMarkdownManyData, RenderMarkdownManyResponse, ListApiTokensResponse, CreateApiTokenData, CreateApiTokenResponse, RevokeApiTokenData, RevokeApiTokenResponse, PluginsInstallFromUrlData, PluginsInstallFromUrlResponse, PluginsGetManifestResponse, PluginsUninstallData, PluginsUninstallResponse, SseUpdatesResponse, GetUserShortcutsResponse, UpdateUserShortcutsData, UpdateUserShortcutsResponse, PluginsGetKvData, PluginsGetKvResponse, PluginsPutKvData, PluginsPutKvResponse, ListRecordsData, ListRecordsResponse, PluginsCreateRecordData, PluginsCreateRecordResponse, PluginsExecActionData, PluginsExecActionResponse, PluginsDeleteRecordData, PluginsDeleteRecordResponse, PluginsUpdateRecordData, PluginsUpdateRecordResponse, GetPublishStatusData, GetPublishStatusResponse, PublishDocumentData, PublishDocumentResponse, UnpublishDocumentData, UnpublishDocumentResponse, ListWorkspacePublicDocumentsData, ListWorkspacePublicDocumentsResponse, GetPublicByWorkspaceAndIdData, GetPublicByWorkspaceAndIdResponse, GetPublicContentByWorkspaceAndIdData, GetPublicContentByWorkspaceAndIdResponse, CreateShareData, CreateShareResponse2, ListActiveSharesResponse, ListApplicableSharesData, ListApplicableSharesResponse, BrowseShareData, BrowseShareResponse, ListDocumentSharesData, ListDocumentSharesResponse, MaterializeFolderShareData, MaterializeFolderShareResponse, ListShareMountsResponse, CreateShareMountData, CreateShareMountResponse, DeleteShareMountData, DeleteShareMountResponse, ValidateShareTokenData, ValidateShareTokenResponse, DeleteShareData, DeleteShareResponse, ListTagsData, ListTagsResponse, AcceptInvitationData, AcceptInvitationResponse, ListWorkspacesResponse, CreateWorkspaceData, CreateWorkspaceResponse, GetWorkspaceDetailData, GetWorkspaceDetailResponse, UpdateWorkspaceData, UpdateWorkspaceResponse, DeleteWorkspaceData, DeleteWorkspaceResponse, DownloadWorkspaceArchiveData, DownloadWorkspaceArchiveResponse, ListInvitationsData, ListInvitationsResponse, CreateInvitationData, CreateInvitationResponse, RevokeInvitationData, RevokeInvitationResponse, ListMembersData, ListMembersResponse, RemoveMemberData, RemoveMemberResponse, UpdateMemberRoleData, UpdateMemberRoleResponse, GetWorkspacePermissionsData, GetWorkspacePermissionsResponse, ListRolesData, ListRolesResponse, CreateRoleData, CreateRoleResponse, DeleteRoleData, DeleteRoleResponse, UpdateRoleData, UpdateRoleResponse, SwitchWorkspaceData, SwitchWorkspaceResponse2, AxumWsEntryData } from './types.gen'; /** * @param data The data for the request. @@ -764,6 +764,21 @@ export const ignoreFolder = (data: IgnoreFolderData): CancelablePromise => { + return __request(OpenAPI, { + method: 'POST', + url: '/api/git/import', + body: data.requestBody, + mediaType: 'application/json' + }); +}; + /** * @returns unknown OK * @throws ApiError diff --git a/app/src/shared/api/client/types.gen.ts b/app/src/shared/api/client/types.gen.ts index 79ca3c4e..72317753 100644 --- a/app/src/shared/api/client/types.gen.ts +++ b/app/src/shared/api/client/types.gen.ts @@ -245,6 +245,15 @@ export type GitHistoryResponse = { commits: Array; }; +export type GitImportResponse = { + attachments_created: number; + commit_hash?: (string) | null; + docs_created: number; + files_changed: number; + message: string; + success: boolean; +}; + export type GitPullConflictItem = { base?: (string) | null; document_id?: (string) | null; @@ -1137,6 +1146,12 @@ export type IgnoreFolderData = { export type IgnoreFolderResponse = (unknown); +export type ImportRepositoryData = { + requestBody: CreateGitConfigRequest; +}; + +export type ImportRepositoryResponse = (GitImportResponse); + export type InitRepositoryResponse = (unknown); export type PullRepositoryData = { diff --git a/app/src/widgets/document/DocumentPage.tsx b/app/src/widgets/document/DocumentPage.tsx index 0c7078cb..a021ea75 100644 --- a/app/src/widgets/document/DocumentPage.tsx +++ b/app/src/widgets/document/DocumentPage.tsx @@ -436,11 +436,9 @@ function DocumentClient({ navigate({ to: '/document/$id', params: { id }, - search: (prev: Record) => { - const next = { ...prev } - if (next && Object.prototype.hasOwnProperty.call(next, 'conflict')) { - delete (next as any).conflict - } + search: (prev: Record) => { + const next: Record = { ...prev } + delete next.conflict return next }, replace: true, diff --git a/app/src/widgets/routes/PluginFallback.tsx b/app/src/widgets/routes/PluginFallback.tsx index 4d132cf2..69d2595d 100644 --- a/app/src/widgets/routes/PluginFallback.tsx +++ b/app/src/widgets/routes/PluginFallback.tsx @@ -20,7 +20,12 @@ export default function PluginFallback() { const realtime = useRealtime() const shareTokenFromContext = useShareToken() const routerState = useRouterState() - const search = routerState.location?.search ?? '' + const searchValue = routerState.location?.search + const search = typeof searchValue === 'string' + ? searchValue + : Array.isArray(searchValue) + ? searchValue.join('&') + : '' const authReady = !authLoading && !!user const shareToken = React.useMemo(() => { if (typeof shareTokenFromContext === 'string' && shareTokenFromContext.length > 0) { diff --git a/app/src/widgets/settings/GitSyncPage.tsx b/app/src/widgets/settings/GitSyncPage.tsx index a437ca50..539b477c 100644 --- a/app/src/widgets/settings/GitSyncPage.tsx +++ b/app/src/widgets/settings/GitSyncPage.tsx @@ -18,6 +18,7 @@ import { getConfig, getStatus, initRepository, + importRepository, } from '@/entities/git' import { settingsNavItems } from '@/features/settings/nav' @@ -93,6 +94,36 @@ export default function GitSyncPage() { }, }) + const importMutation = useMutation({ + mutationFn: async () => { + if (!repositoryUrl.trim()) throw new Error('Repository URL is required') + const auth_data = resolveAuthData() + return importRepository({ + requestBody: { + repository_url: repositoryUrl.trim(), + branch_name: branchName.trim() || 'main', + auth_type: authType, + auth_data, + auto_sync: autoSync, + }, + }) + }, + onSuccess: (data: any) => { + const msg = data?.message || 'Imported from Git' + const docs = data?.docs_created ?? 0 + const attachments = data?.attachments_created ?? 0 + const extra = + docs || attachments ? ` (${docs} docs, ${attachments} attachments)` : '' + toast.success(`${msg}${extra}`) + qc.invalidateQueries({ queryKey: ['git-status'] }) + qc.invalidateQueries({ queryKey: ['git-config'] }) + }, + onError: (e: any) => { + const raw = e?.body?.message || e?.message || `${e}` + toast.error(`Import failed: ${raw}`) + }, + }) + const initMutation = useMutation({ mutationFn: () => initRepository(), onSuccess: () => { @@ -286,7 +317,7 @@ export default function GitSyncPage() {

- Auto sync is off. Use Pull to fetch remote changes and Sync to push manually. + Auto sync is off. Use Pull to fetch remote changes and Sync to push manually. Use Import to populate this workspace from the remote repository.

@@ -297,6 +328,18 @@ export default function GitSyncPage() { > {saveMutation.isPending ? 'Saving…' : 'Save settings'} +
diff --git a/app/src/widgets/sidebar/FileTree.tsx b/app/src/widgets/sidebar/FileTree.tsx index dc7a1f73..b0e18f3d 100644 --- a/app/src/widgets/sidebar/FileTree.tsx +++ b/app/src/widgets/sidebar/FileTree.tsx @@ -470,12 +470,11 @@ function FileTreeInner() { await router.navigate({ to: '/document/$id', params: { id: targetId }, - search: (prev: Record) => { - const next = { ...prev } - next.token = node.shareToken - next.shareMount = '1' - return next - }, + search: (prev: Record) => ({ + ...(prev || {}), + token: node.shareToken, + shareMount: '1', + }), }) return } diff --git a/app/src/widgets/workspaces/WorkspacesPage.tsx b/app/src/widgets/workspaces/WorkspacesPage.tsx index ebfece0c..092a65f1 100644 --- a/app/src/widgets/workspaces/WorkspacesPage.tsx +++ b/app/src/widgets/workspaces/WorkspacesPage.tsx @@ -5,6 +5,7 @@ import { useEffect, useMemo, useState } from 'react' import { toast } from 'sonner' import { ApiError } from '@/shared/api' +import { setClientWorkspaceId } from '@/shared/api/client.config' import { overlayPanelClass } from '@/shared/lib/overlay-classes' import { cn } from '@/shared/lib/utils' import { Badge } from '@/shared/ui/badge' @@ -16,6 +17,7 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@ import { Switch } from '@/shared/ui/switch' import { Textarea } from '@/shared/ui/textarea' +import { importRepository } from '@/entities/git' import { me as meApi, userKeys } from '@/entities/user' import { listWorkspaceInvitations, @@ -102,6 +104,13 @@ export default function WorkspacesPage() { const [createName, setCreateName] = useState('') const [createDescription, setCreateDescription] = useState('') const [creating, setCreating] = useState(false) + const [enableGitImport, setEnableGitImport] = useState(false) + const [importRepoUrl, setImportRepoUrl] = useState('') + const [importBranch, setImportBranch] = useState('main') + const [importAuthType, setImportAuthType] = useState<'token' | 'ssh'>('token') + const [importToken, setImportToken] = useState('') + const [importPrivateKey, setImportPrivateKey] = useState('') + const [importing, setImporting] = useState(false) const [switchingId, setSwitchingId] = useState(null) const [inviteOpen, setInviteOpen] = useState(false) const [inviteEmail, setInviteEmail] = useState('') @@ -263,19 +272,54 @@ export default function WorkspacesPage() { return } setCreating(true) + setImporting(false) try { - await createWorkspaceAction({ name: createName, description: createDescription }) + const workspace = await createWorkspaceAction({ name: createName, description: createDescription }) + const workspaceId = workspace?.id + if (workspaceId) { + setClientWorkspaceId(workspaceId) + } const updated = await meApi() queryClient.setQueryData(userKeys.me(), updated) - toast.success('Workspace created') + if (enableGitImport && importRepoUrl.trim() && workspaceId) { + setImporting(true) + const auth_data = + importAuthType === 'ssh' + ? { private_key: importPrivateKey || undefined } + : { token: importToken || undefined } + try { + const res = await importRepository({ + requestBody: { + repository_url: importRepoUrl.trim(), + branch_name: importBranch.trim() || undefined, + auth_type: importAuthType, + auth_data, + auto_sync: false, + }, + }) + toast.success(res?.message || 'Imported from Git') + } catch (err) { + console.error('[workspaces] git import failed', err) + const raw = (err as any)?.body?.message || (err as any)?.message || 'Git import failed' + toast.error(raw) + } + } else { + toast.success('Workspace created') + } setCreateName('') setCreateDescription('') + setEnableGitImport(false) + setImportRepoUrl('') + setImportBranch('main') + setImportToken('') + setImportPrivateKey('') setCreateOpen(false) } catch (error) { console.error('[workspaces] create failed', error) const message = error instanceof Error ? error.message : 'Failed to create workspace' toast.error(message) } finally { + setImporting(false) setCreating(false) } } @@ -1081,13 +1125,83 @@ export default function WorkspacesPage() { onChange={(event) => setCreateDescription(event.target.value)} />
+
+
+
+

Import from Git (optional)

+

+ Clone documents and attachments from a Git repo into this new workspace. History is preserved. +

+
+ +
+ {enableGitImport ? ( +
+
+ + setImportRepoUrl(event.target.value)} + /> +
+
+
+ + setImportBranch(event.target.value)} + /> +
+
+ + +
+
+ {importAuthType === 'token' ? ( +
+ + setImportToken(event.target.value)} + /> +
+ ) : ( +
+ +