Compare commits
4 Commits
Author | SHA1 | Date | |
---|---|---|---|
4b2c828dd6 | |||
11a9b23802 | |||
719bfafb93 | |||
d493d9fd01 |
1
.serena/.gitignore
vendored
Normal file
1
.serena/.gitignore
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/cache
|
67
.serena/project.yml
Normal file
67
.serena/project.yml
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
# language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby)
|
||||||
|
# * For C, use cpp
|
||||||
|
# * For JavaScript, use typescript
|
||||||
|
# Special requirements:
|
||||||
|
# * csharp: Requires the presence of a .sln file in the project folder.
|
||||||
|
language: typescript
|
||||||
|
|
||||||
|
# whether to use the project's gitignore file to ignore files
|
||||||
|
# Added on 2025-04-07
|
||||||
|
ignore_all_files_in_gitignore: true
|
||||||
|
# list of additional paths to ignore
|
||||||
|
# same syntax as gitignore, so you can use * and **
|
||||||
|
# Was previously called `ignored_dirs`, please update your config if you are using that.
|
||||||
|
# Added (renamed) on 2025-04-07
|
||||||
|
ignored_paths: []
|
||||||
|
|
||||||
|
# whether the project is in read-only mode
|
||||||
|
# If set to true, all editing tools will be disabled and attempts to use them will result in an error
|
||||||
|
# Added on 2025-04-18
|
||||||
|
read_only: false
|
||||||
|
|
||||||
|
# list of tool names to exclude. We recommend not excluding any tools, see the readme for more details.
|
||||||
|
# Below is the complete list of tools for convenience.
|
||||||
|
# To make sure you have the latest list of tools, and to view their descriptions,
|
||||||
|
# execute `uv run scripts/print_tool_overview.py`.
|
||||||
|
#
|
||||||
|
# * `activate_project`: Activates a project by name.
|
||||||
|
# * `check_onboarding_performed`: Checks whether project onboarding was already performed.
|
||||||
|
# * `create_text_file`: Creates/overwrites a file in the project directory.
|
||||||
|
# * `delete_lines`: Deletes a range of lines within a file.
|
||||||
|
# * `delete_memory`: Deletes a memory from Serena's project-specific memory store.
|
||||||
|
# * `execute_shell_command`: Executes a shell command.
|
||||||
|
# * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced.
|
||||||
|
# * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type).
|
||||||
|
# * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type).
|
||||||
|
# * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes.
|
||||||
|
# * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file.
|
||||||
|
# * `initial_instructions`: Gets the initial instructions for the current project.
|
||||||
|
# Should only be used in settings where the system prompt cannot be set,
|
||||||
|
# e.g. in clients you have no control over, like Claude Desktop.
|
||||||
|
# * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol.
|
||||||
|
# * `insert_at_line`: Inserts content at a given line in a file.
|
||||||
|
# * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol.
|
||||||
|
# * `list_dir`: Lists files and directories in the given directory (optionally with recursion).
|
||||||
|
# * `list_memories`: Lists memories in Serena's project-specific memory store.
|
||||||
|
# * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building).
|
||||||
|
# * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context).
|
||||||
|
# * `read_file`: Reads a file within the project directory.
|
||||||
|
# * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store.
|
||||||
|
# * `remove_project`: Removes a project from the Serena configuration.
|
||||||
|
# * `replace_lines`: Replaces a range of lines within a file with new content.
|
||||||
|
# * `replace_symbol_body`: Replaces the full definition of a symbol.
|
||||||
|
# * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen.
|
||||||
|
# * `search_for_pattern`: Performs a search for a pattern in the project.
|
||||||
|
# * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase.
|
||||||
|
# * `switch_modes`: Activates modes by providing a list of their names
|
||||||
|
# * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information.
|
||||||
|
# * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task.
|
||||||
|
# * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed.
|
||||||
|
# * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store.
|
||||||
|
excluded_tools: []
|
||||||
|
|
||||||
|
# initial prompt for the project. It will always be given to the LLM upon activating the project
|
||||||
|
# (contrary to the memories, which are loaded on demand).
|
||||||
|
initial_prompt: ""
|
||||||
|
|
||||||
|
project_name: "ghost"
|
19
changelog.md
19
changelog.md
@@ -1,5 +1,24 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 2025-10-10 - 2.2.0 - feat(apiclient)
|
||||||
|
Add native Admin & Content API clients, JWT generator, and tag visibility features; remove external @tryghost deps and update docs
|
||||||
|
|
||||||
|
- Add native GhostAdminAPI and GhostContentAPI implementations (ts/apiclient/*) using global fetch with support for browse/read/add/edit/delete, webhooks, and image upload.
|
||||||
|
- Add generateToken JWT implementation compatible with Ghost Admin API (ts/apiclient/ghost.jwt.ts) with Web Crypto and Node fallbacks.
|
||||||
|
- Switch Ghost class to use built-in apiclient implementations and bump default API version to v6.0.
|
||||||
|
- Use Admin API to fetch ALL tags (including zero-count) and add visibility filtering and minimatch filtering in getTags; add convenience methods getPublicTags and getInternalTags.
|
||||||
|
- Add Tag methods getVisibility(), isInternal(), and isPublic() to expose visibility information.
|
||||||
|
- Replace imports of @tryghost/* with local apiclient implementations in ts/ghost.plugins.ts and remove @tryghost dependencies from package.json.
|
||||||
|
- Update README to document tag visibility, new tag-related APIs/examples, and other notes about using Admin API for tags.
|
||||||
|
|
||||||
|
## 2025-10-08 - 2.1.0 - feat(syncedinstance)
|
||||||
|
Add SyncedInstance for multi-instance content synchronization, export it, add tests, and expand README
|
||||||
|
|
||||||
|
- Introduce SyncedInstance class (ts/classes.syncedinstance.ts) to synchronize tags, posts and pages across Ghost instances with mapping and history support. New APIs: syncTags, syncPosts, syncPages, syncAll, getSyncStatus, clearSyncHistory, clearMappings.
|
||||||
|
- Export SyncedInstance from the package entry point (ts/index.ts).
|
||||||
|
- Add integration tests for SyncedInstance (test/test.syncedinstance.node.ts).
|
||||||
|
- Expand README (readme.md) with Quick Start, detailed Multi-Instance Synchronization docs, examples and updated API reference.
|
||||||
|
|
||||||
## 2025-10-08 - 2.0.0 - BREAKING CHANGE(classes.ghost)
|
## 2025-10-08 - 2.0.0 - BREAKING CHANGE(classes.ghost)
|
||||||
Remove Settings and Webhooks browse/read APIs, remove noisy console.error logs, and update tests/docs
|
Remove Settings and Webhooks browse/read APIs, remove noisy console.error logs, and update tests/docs
|
||||||
|
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@apiclient.xyz/ghost",
|
"name": "@apiclient.xyz/ghost",
|
||||||
"version": "2.0.0",
|
"version": "2.2.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.",
|
"description": "An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.",
|
||||||
"main": "dist_ts/index.js",
|
"main": "dist_ts/index.js",
|
||||||
@@ -23,9 +23,7 @@
|
|||||||
"@types/node": "^22.12.0"
|
"@types/node": "^22.12.0"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@push.rocks/smartmatch": "^2.0.0",
|
"@push.rocks/smartmatch": "^2.0.0"
|
||||||
"@tryghost/admin-api": "^1.14.0",
|
|
||||||
"@tryghost/content-api": "^1.12.0"
|
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
110
pnpm-lock.yaml
generated
110
pnpm-lock.yaml
generated
@@ -11,12 +11,6 @@ importers:
|
|||||||
'@push.rocks/smartmatch':
|
'@push.rocks/smartmatch':
|
||||||
specifier: ^2.0.0
|
specifier: ^2.0.0
|
||||||
version: 2.0.0
|
version: 2.0.0
|
||||||
'@tryghost/admin-api':
|
|
||||||
specifier: ^1.14.0
|
|
||||||
version: 1.14.0
|
|
||||||
'@tryghost/content-api':
|
|
||||||
specifier: ^1.12.0
|
|
||||||
version: 1.12.0
|
|
||||||
devDependencies:
|
devDependencies:
|
||||||
'@git.zone/tsbuild':
|
'@git.zone/tsbuild':
|
||||||
specifier: ^2.6.8
|
specifier: ^2.6.8
|
||||||
@@ -1537,12 +1531,6 @@ packages:
|
|||||||
'@tootallnate/quickjs-emscripten@0.23.0':
|
'@tootallnate/quickjs-emscripten@0.23.0':
|
||||||
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
resolution: {integrity: sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==}
|
||||||
|
|
||||||
'@tryghost/admin-api@1.14.0':
|
|
||||||
resolution: {integrity: sha512-FKAs4uHYhgqAXqRHdGNvODq60vK0H9FK2f26ib50dKsoSwB/vTspXxjcmqlPLyZfeQpOnaRlFK+ykpriYCOuTg==}
|
|
||||||
|
|
||||||
'@tryghost/content-api@1.12.0':
|
|
||||||
resolution: {integrity: sha512-rU9yrBHlVIohOLSpC9PeH9evMeNCk4OKGvI0VEwNwuwWlsQIpBn3o79DzleEN0tbMynlObYAG0IJ/EGkIumtJA==}
|
|
||||||
|
|
||||||
'@tsclass/tsclass@3.0.48':
|
'@tsclass/tsclass@3.0.48':
|
||||||
resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==}
|
resolution: {integrity: sha512-hC65UvDlp9qvsl6OcIZXz0JNiWZ0gyzsTzbXpg215sGxopgbkOLCr6E0s4qCTnweYm95gt2AdY95uP7M7kExaQ==}
|
||||||
|
|
||||||
@@ -1997,9 +1985,6 @@ packages:
|
|||||||
buffer-crc32@0.2.13:
|
buffer-crc32@0.2.13:
|
||||||
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
|
resolution: {integrity: sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=}
|
||||||
|
|
||||||
buffer-equal-constant-time@1.0.1:
|
|
||||||
resolution: {integrity: sha1-+OcRMvf/5uAaXJaXpMbz5I1cyBk=}
|
|
||||||
|
|
||||||
buffer-json@2.0.0:
|
buffer-json@2.0.0:
|
||||||
resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==}
|
resolution: {integrity: sha512-+jjPFVqyfF1esi9fvfUs3NqM0pH1ziZ36VP4hmA/y/Ssfo/5w5xHKfTw9BwQjoJ1w/oVtpLomqwUHKdefGyuHw==}
|
||||||
|
|
||||||
@@ -2358,9 +2343,6 @@ packages:
|
|||||||
eastasianwidth@0.2.0:
|
eastasianwidth@0.2.0:
|
||||||
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==}
|
||||||
|
|
||||||
ecdsa-sig-formatter@1.0.11:
|
|
||||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
|
||||||
|
|
||||||
ee-first@1.1.1:
|
ee-first@1.1.1:
|
||||||
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
|
resolution: {integrity: sha1-WQxhFWsK4vTwJVcyoViyZrxWsh0=}
|
||||||
|
|
||||||
@@ -3066,16 +3048,6 @@ packages:
|
|||||||
jsonfile@6.2.0:
|
jsonfile@6.2.0:
|
||||||
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
resolution: {integrity: sha512-FGuPw30AdOIUTRMC2OMRtQV+jkVj2cfPqSeWXv1NEAJ1qZ5zb1X6z1mFhbfOB/iy3ssJCD+3KuZ8r8C3uVFlAg==}
|
||||||
|
|
||||||
jsonwebtoken@9.0.2:
|
|
||||||
resolution: {integrity: sha512-PRp66vJ865SSqOlgqS8hujT5U4AOgMfhrwYIuIhfKaoSCZcirrmASQr8CX7cUg+RMih+hgznrjp99o+W4pJLHQ==}
|
|
||||||
engines: {node: '>=12', npm: '>=6'}
|
|
||||||
|
|
||||||
jwa@1.4.2:
|
|
||||||
resolution: {integrity: sha512-eeH5JO+21J78qMvTIDdBXidBd6nG2kZjg5Ohz/1fpa28Z4CcsWUzJ1ZZyFq/3z3N17aZy+ZuBoHljASbL1WfOw==}
|
|
||||||
|
|
||||||
jws@3.2.2:
|
|
||||||
resolution: {integrity: sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==}
|
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
resolution: {integrity: sha512-iYSchDJ+liQ8iwbSI2QqsQOvqv58eJCEanyJPJi+Khyu8smkcKSFUCbPwzFcL7YVtZ6eONjqRX/38caJ7QjRAQ==}
|
||||||
engines: {node: '>= 0.6'}
|
engines: {node: '>= 0.6'}
|
||||||
@@ -3170,36 +3142,15 @@ packages:
|
|||||||
lodash.clonedeep@4.5.0:
|
lodash.clonedeep@4.5.0:
|
||||||
resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=}
|
resolution: {integrity: sha1-4j8/nE+Pvd6HJSnBBxhXoIblzO8=}
|
||||||
|
|
||||||
lodash.includes@4.3.0:
|
|
||||||
resolution: {integrity: sha1-YLuYqHy5I8aMoeUTJUgzFISfVT8=}
|
|
||||||
|
|
||||||
lodash.isarguments@3.1.0:
|
lodash.isarguments@3.1.0:
|
||||||
resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=}
|
resolution: {integrity: sha1-L1c9hcaiQon/AGY7SRwdM4/zRYo=}
|
||||||
|
|
||||||
lodash.isarray@3.0.4:
|
lodash.isarray@3.0.4:
|
||||||
resolution: {integrity: sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=}
|
resolution: {integrity: sha1-eeTriMNqgSKvhvhEqpvNhRtfu1U=}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3:
|
|
||||||
resolution: {integrity: sha1-bC4XHbKiV82WgC/UOwGyDV9YcPY=}
|
|
||||||
|
|
||||||
lodash.isinteger@4.0.4:
|
|
||||||
resolution: {integrity: sha1-YZwK89A/iwTDH1iChAt3sRzWg0M=}
|
|
||||||
|
|
||||||
lodash.isnumber@3.0.3:
|
|
||||||
resolution: {integrity: sha1-POdoEMWSjQM1IwGsKHMX8RwLH/w=}
|
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6:
|
|
||||||
resolution: {integrity: sha1-fFJqUtibRcRcxpC4gWO+BJf1UMs=}
|
|
||||||
|
|
||||||
lodash.isstring@4.0.1:
|
|
||||||
resolution: {integrity: sha1-1SfftUVuynzJu5XV2ur4i6VKVFE=}
|
|
||||||
|
|
||||||
lodash.keys@3.1.2:
|
lodash.keys@3.1.2:
|
||||||
resolution: {integrity: sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=}
|
resolution: {integrity: sha1-TbwEcrFWvlCgsoaFXRvQsMZWCYo=}
|
||||||
|
|
||||||
lodash.once@4.1.1:
|
|
||||||
resolution: {integrity: sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=}
|
|
||||||
|
|
||||||
lodash.restparam@3.6.1:
|
lodash.restparam@3.6.1:
|
||||||
resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=}
|
resolution: {integrity: sha1-k2pOMJ7zMKdkXtQUWYbIWuWyCAU=}
|
||||||
|
|
||||||
@@ -6768,19 +6719,16 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@aws-sdk/credential-providers'
|
- '@aws-sdk/credential-providers'
|
||||||
- '@mongodb-js/zstd'
|
- '@mongodb-js/zstd'
|
||||||
- '@nuxt/kit'
|
|
||||||
- aws-crt
|
- aws-crt
|
||||||
- bufferutil
|
- bufferutil
|
||||||
- gcp-metadata
|
- gcp-metadata
|
||||||
- kerberos
|
- kerberos
|
||||||
- mongodb-client-encryption
|
- mongodb-client-encryption
|
||||||
- react
|
|
||||||
- react-native-b4a
|
- react-native-b4a
|
||||||
- snappy
|
- snappy
|
||||||
- socks
|
- socks
|
||||||
- supports-color
|
- supports-color
|
||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vue
|
|
||||||
|
|
||||||
'@push.rocks/taskbuffer@3.4.0':
|
'@push.rocks/taskbuffer@3.4.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
@@ -7337,20 +7285,6 @@ snapshots:
|
|||||||
|
|
||||||
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
'@tootallnate/quickjs-emscripten@0.23.0': {}
|
||||||
|
|
||||||
'@tryghost/admin-api@1.14.0':
|
|
||||||
dependencies:
|
|
||||||
axios: 1.12.2(debug@4.4.3)
|
|
||||||
form-data: 4.0.4
|
|
||||||
jsonwebtoken: 9.0.2
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- debug
|
|
||||||
|
|
||||||
'@tryghost/content-api@1.12.0':
|
|
||||||
dependencies:
|
|
||||||
axios: 1.12.2(debug@4.4.3)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- debug
|
|
||||||
|
|
||||||
'@tsclass/tsclass@3.0.48':
|
'@tsclass/tsclass@3.0.48':
|
||||||
dependencies:
|
dependencies:
|
||||||
type-fest: 2.19.0
|
type-fest: 2.19.0
|
||||||
@@ -7892,8 +7826,6 @@ snapshots:
|
|||||||
|
|
||||||
buffer-crc32@0.2.13: {}
|
buffer-crc32@0.2.13: {}
|
||||||
|
|
||||||
buffer-equal-constant-time@1.0.1: {}
|
|
||||||
|
|
||||||
buffer-json@2.0.0: {}
|
buffer-json@2.0.0: {}
|
||||||
|
|
||||||
buffer@6.0.3:
|
buffer@6.0.3:
|
||||||
@@ -8216,10 +8148,6 @@ snapshots:
|
|||||||
|
|
||||||
eastasianwidth@0.2.0: {}
|
eastasianwidth@0.2.0: {}
|
||||||
|
|
||||||
ecdsa-sig-formatter@1.0.11:
|
|
||||||
dependencies:
|
|
||||||
safe-buffer: 5.2.1
|
|
||||||
|
|
||||||
ee-first@1.1.1: {}
|
ee-first@1.1.1: {}
|
||||||
|
|
||||||
elliptic@6.6.1:
|
elliptic@6.6.1:
|
||||||
@@ -9090,30 +9018,6 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
graceful-fs: 4.2.11
|
graceful-fs: 4.2.11
|
||||||
|
|
||||||
jsonwebtoken@9.0.2:
|
|
||||||
dependencies:
|
|
||||||
jws: 3.2.2
|
|
||||||
lodash.includes: 4.3.0
|
|
||||||
lodash.isboolean: 3.0.3
|
|
||||||
lodash.isinteger: 4.0.4
|
|
||||||
lodash.isnumber: 3.0.3
|
|
||||||
lodash.isplainobject: 4.0.6
|
|
||||||
lodash.isstring: 4.0.1
|
|
||||||
lodash.once: 4.1.1
|
|
||||||
ms: 2.1.3
|
|
||||||
semver: 7.7.2
|
|
||||||
|
|
||||||
jwa@1.4.2:
|
|
||||||
dependencies:
|
|
||||||
buffer-equal-constant-time: 1.0.1
|
|
||||||
ecdsa-sig-formatter: 1.0.11
|
|
||||||
safe-buffer: 5.2.1
|
|
||||||
|
|
||||||
jws@3.2.2:
|
|
||||||
dependencies:
|
|
||||||
jwa: 1.4.2
|
|
||||||
safe-buffer: 5.2.1
|
|
||||||
|
|
||||||
keygrip@1.1.0:
|
keygrip@1.1.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
tsscmp: 1.0.6
|
tsscmp: 1.0.6
|
||||||
@@ -9238,30 +9142,16 @@ snapshots:
|
|||||||
|
|
||||||
lodash.clonedeep@4.5.0: {}
|
lodash.clonedeep@4.5.0: {}
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
|
||||||
|
|
||||||
lodash.isarguments@3.1.0: {}
|
lodash.isarguments@3.1.0: {}
|
||||||
|
|
||||||
lodash.isarray@3.0.4: {}
|
lodash.isarray@3.0.4: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
|
||||||
|
|
||||||
lodash.isinteger@4.0.4: {}
|
|
||||||
|
|
||||||
lodash.isnumber@3.0.3: {}
|
|
||||||
|
|
||||||
lodash.isplainobject@4.0.6: {}
|
|
||||||
|
|
||||||
lodash.isstring@4.0.1: {}
|
|
||||||
|
|
||||||
lodash.keys@3.1.2:
|
lodash.keys@3.1.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
lodash._getnative: 3.9.1
|
lodash._getnative: 3.9.1
|
||||||
lodash.isarguments: 3.1.0
|
lodash.isarguments: 3.1.0
|
||||||
lodash.isarray: 3.0.4
|
lodash.isarray: 3.0.4
|
||||||
|
|
||||||
lodash.once@4.1.1: {}
|
|
||||||
|
|
||||||
lodash.restparam@3.6.1: {}
|
lodash.restparam@3.6.1: {}
|
||||||
|
|
||||||
lodash@4.17.21: {}
|
lodash@4.17.21: {}
|
||||||
|
185
test/test.syncedinstance.node.ts
Normal file
185
test/test.syncedinstance.node.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
import { expect, tap } from '@push.rocks/tapbundle';
|
||||||
|
import * as qenv from '@push.rocks/qenv';
|
||||||
|
const testQenv = new qenv.Qenv('./', './.nogit/');
|
||||||
|
|
||||||
|
import * as ghost from '../ts/index.js';
|
||||||
|
|
||||||
|
let sourceGhost: ghost.Ghost;
|
||||||
|
let targetGhost: ghost.Ghost;
|
||||||
|
let syncedInstance: ghost.SyncedInstance;
|
||||||
|
let testTag: ghost.Tag;
|
||||||
|
let testPost: ghost.Post;
|
||||||
|
let testPage: ghost.Page;
|
||||||
|
|
||||||
|
tap.test('initialize source and target Ghost instances', async () => {
|
||||||
|
sourceGhost = new ghost.Ghost({
|
||||||
|
baseUrl: 'http://localhost:2368',
|
||||||
|
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
|
||||||
|
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
|
||||||
|
});
|
||||||
|
|
||||||
|
targetGhost = new ghost.Ghost({
|
||||||
|
baseUrl: 'http://localhost:2368',
|
||||||
|
adminApiKey: await testQenv.getEnvVarOnDemand('ADMIN_APIKEY'),
|
||||||
|
contentApiKey: await testQenv.getEnvVarOnDemand('CONTENT_APIKEY'),
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(sourceGhost).toBeInstanceOf(ghost.Ghost);
|
||||||
|
expect(targetGhost).toBeInstanceOf(ghost.Ghost);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('create SyncedInstance', async () => {
|
||||||
|
syncedInstance = new ghost.SyncedInstance(sourceGhost, [targetGhost]);
|
||||||
|
expect(syncedInstance).toBeInstanceOf(ghost.SyncedInstance);
|
||||||
|
expect(syncedInstance.sourceGhost).toEqual(sourceGhost);
|
||||||
|
expect(syncedInstance.targetGhosts).toBeArray();
|
||||||
|
expect(syncedInstance.targetGhosts.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('create test tag on source', async () => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
testTag = await sourceGhost.createTag({
|
||||||
|
name: `Sync Test Tag ${timestamp}`,
|
||||||
|
slug: `sync-test-tag-${timestamp}`,
|
||||||
|
description: 'This is a test tag for syncing'
|
||||||
|
});
|
||||||
|
expect(testTag).toBeInstanceOf(ghost.Tag);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('sync tags from source to target', async () => {
|
||||||
|
const report = await syncedInstance.syncTags();
|
||||||
|
|
||||||
|
expect(report).toBeTruthy();
|
||||||
|
expect(report.contentType).toEqual('tags');
|
||||||
|
expect(report.totalItems).toBeGreaterThan(0);
|
||||||
|
expect(report.targetReports).toBeArray();
|
||||||
|
expect(report.targetReports.length).toEqual(1);
|
||||||
|
|
||||||
|
const targetReport = report.targetReports[0];
|
||||||
|
expect(targetReport.results).toBeArray();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify sync status was tracked', async () => {
|
||||||
|
const status = syncedInstance.getSyncStatus();
|
||||||
|
expect(status.totalMappings).toBeGreaterThan(0);
|
||||||
|
expect(status.recentSyncs).toBeArray();
|
||||||
|
expect(status.recentSyncs.length).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('create test post on source', async () => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
testPost = await sourceGhost.createPost({
|
||||||
|
title: `Sync Test Post ${timestamp}`,
|
||||||
|
slug: `sync-test-post-${timestamp}`,
|
||||||
|
html: '<p>This is a test post for syncing</p>',
|
||||||
|
status: 'published',
|
||||||
|
tags: [{ id: testTag.tagData.id }]
|
||||||
|
});
|
||||||
|
expect(testPost).toBeInstanceOf(ghost.Post);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('sync posts from source to target', async () => {
|
||||||
|
const report = await syncedInstance.syncPosts();
|
||||||
|
|
||||||
|
expect(report).toBeTruthy();
|
||||||
|
expect(report.contentType).toEqual('posts');
|
||||||
|
expect(report.totalItems).toBeGreaterThan(0);
|
||||||
|
expect(report.targetReports).toBeArray();
|
||||||
|
expect(report.targetReports.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify post sync in status', async () => {
|
||||||
|
const status = syncedInstance.getSyncStatus();
|
||||||
|
expect(status.recentSyncs).toBeArray();
|
||||||
|
const postSync = status.recentSyncs.find(s => s.contentType === 'posts');
|
||||||
|
expect(postSync).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('create test page on source', async () => {
|
||||||
|
const timestamp = Date.now();
|
||||||
|
testPage = await sourceGhost.createPage({
|
||||||
|
title: `Sync Test Page ${timestamp}`,
|
||||||
|
slug: `sync-test-page-${timestamp}`,
|
||||||
|
html: '<p>This is a test page for syncing</p>',
|
||||||
|
status: 'published'
|
||||||
|
});
|
||||||
|
expect(testPage).toBeInstanceOf(ghost.Page);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('sync pages from source to target', async () => {
|
||||||
|
const report = await syncedInstance.syncPages();
|
||||||
|
|
||||||
|
expect(report).toBeTruthy();
|
||||||
|
expect(report.contentType).toEqual('pages');
|
||||||
|
expect(report.totalItems).toBeGreaterThan(0);
|
||||||
|
expect(report.targetReports).toBeArray();
|
||||||
|
expect(report.targetReports.length).toEqual(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('verify page sync in status', async () => {
|
||||||
|
const status = syncedInstance.getSyncStatus();
|
||||||
|
const pageSync = status.recentSyncs.find(s => s.contentType === 'pages');
|
||||||
|
expect(pageSync).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test syncAll method', async () => {
|
||||||
|
const reports = await syncedInstance.syncAll({
|
||||||
|
types: ['tags', 'posts', 'pages'],
|
||||||
|
syncOptions: { dryRun: true }
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(reports).toBeArray();
|
||||||
|
expect(reports.length).toEqual(3);
|
||||||
|
expect(reports[0].contentType).toEqual('tags');
|
||||||
|
expect(reports[1].contentType).toEqual('posts');
|
||||||
|
expect(reports[2].contentType).toEqual('pages');
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('test clear methods', async () => {
|
||||||
|
syncedInstance.clearMappings();
|
||||||
|
syncedInstance.clearSyncHistory();
|
||||||
|
|
||||||
|
const status = syncedInstance.getSyncStatus();
|
||||||
|
expect(status.totalMappings).toEqual(0);
|
||||||
|
expect(status.recentSyncs.length).toEqual(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('update tag and re-sync', async () => {
|
||||||
|
await testTag.update({
|
||||||
|
description: 'Updated description for sync test'
|
||||||
|
});
|
||||||
|
|
||||||
|
const report = await syncedInstance.syncTags();
|
||||||
|
expect(report).toBeTruthy();
|
||||||
|
expect(report.totalItems).toBeGreaterThan(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
tap.test('cleanup - delete synced content', async () => {
|
||||||
|
if (testPost) {
|
||||||
|
await testPost.delete();
|
||||||
|
const targetPosts = await targetGhost.getPosts({ filter: `slug:${testPost.postData.slug}` });
|
||||||
|
if (targetPosts.length > 0) {
|
||||||
|
await targetPosts[0].delete();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testPage) {
|
||||||
|
await testPage.delete();
|
||||||
|
try {
|
||||||
|
const targetPage = await targetGhost.getPageBySlug(testPage.pageData.slug);
|
||||||
|
await targetPage.delete();
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (testTag) {
|
||||||
|
await testTag.delete();
|
||||||
|
try {
|
||||||
|
const targetTag = await targetGhost.getTagBySlug(testTag.tagData.slug);
|
||||||
|
await targetTag.delete();
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
export default tap.start();
|
@@ -3,6 +3,6 @@
|
|||||||
*/
|
*/
|
||||||
export const commitinfo = {
|
export const commitinfo = {
|
||||||
name: '@apiclient.xyz/ghost',
|
name: '@apiclient.xyz/ghost',
|
||||||
version: '2.0.0',
|
version: '2.2.0',
|
||||||
description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.'
|
description: 'An unofficial Ghost CMS API package enabling content and admin functionality for managing posts.'
|
||||||
}
|
}
|
||||||
|
318
ts/apiclient/ghost.adminapi.ts
Normal file
318
ts/apiclient/ghost.adminapi.ts
Normal file
@@ -0,0 +1,318 @@
|
|||||||
|
/**
|
||||||
|
* Ghost Admin API Client
|
||||||
|
* Full CRUD operations for Ghost content using native fetch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { generateToken } from './ghost.jwt.js';
|
||||||
|
import type { THttpMethod, IBrowseOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as path from 'path';
|
||||||
|
|
||||||
|
export interface IGhostAdminAPIOptions {
|
||||||
|
url: string;
|
||||||
|
key: string;
|
||||||
|
version?: string;
|
||||||
|
ghostPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GhostAdminAPI {
|
||||||
|
private url: string;
|
||||||
|
private key: string;
|
||||||
|
private version: string;
|
||||||
|
private ghostPath: string;
|
||||||
|
private acceptVersionHeader: string;
|
||||||
|
|
||||||
|
constructor(options: IGhostAdminAPIOptions) {
|
||||||
|
this.url = options.url.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
this.key = options.key;
|
||||||
|
this.version = options.version || 'v3';
|
||||||
|
this.ghostPath = options.ghostPath || 'ghost';
|
||||||
|
|
||||||
|
if (!this.url) {
|
||||||
|
throw new Error('GhostAdminAPI: url is required');
|
||||||
|
}
|
||||||
|
if (!this.key) {
|
||||||
|
throw new Error('GhostAdminAPI: key is required');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set accept version header
|
||||||
|
if (this.version.match(/^v\d+$/)) {
|
||||||
|
this.acceptVersionHeader = `${this.version}.0`;
|
||||||
|
} else if (this.version.match(/^v\d+\.\d+$/)) {
|
||||||
|
this.acceptVersionHeader = this.version;
|
||||||
|
} else {
|
||||||
|
this.acceptVersionHeader = 'v6.0';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the API prefix based on version
|
||||||
|
*/
|
||||||
|
private getAPIPrefix(): string {
|
||||||
|
// v5+ doesn't need version prefix
|
||||||
|
if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) {
|
||||||
|
return `/admin/`;
|
||||||
|
}
|
||||||
|
// v2-v4 and canary need version prefix
|
||||||
|
return `/${this.version}/admin/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full API URL
|
||||||
|
*/
|
||||||
|
private buildUrl(resource: string, identifier?: string, params?: Record<string, any>): string {
|
||||||
|
const apiPrefix = this.getAPIPrefix();
|
||||||
|
let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`;
|
||||||
|
|
||||||
|
if (identifier) {
|
||||||
|
endpoint += `${identifier}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build query string if params exist
|
||||||
|
if (params && Object.keys(params).length > 0) {
|
||||||
|
const queryString = Object.keys(params)
|
||||||
|
.filter(key => params[key] !== undefined && params[key] !== null)
|
||||||
|
.map(key => {
|
||||||
|
const value = Array.isArray(params[key])
|
||||||
|
? params[key].join(',')
|
||||||
|
: params[key];
|
||||||
|
return `${key}=${encodeURIComponent(value)}`;
|
||||||
|
})
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
if (queryString) {
|
||||||
|
endpoint += `?${queryString}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get authorization token
|
||||||
|
*/
|
||||||
|
private async getAuthToken(): Promise<string> {
|
||||||
|
const audience = this.getAPIPrefix();
|
||||||
|
return await generateToken(this.key, audience);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request
|
||||||
|
*/
|
||||||
|
private async makeRequest<T>(
|
||||||
|
resource: string,
|
||||||
|
method: THttpMethod = 'GET',
|
||||||
|
identifier?: string,
|
||||||
|
body?: any,
|
||||||
|
queryParams?: Record<string, any>
|
||||||
|
): Promise<T | T[] | void> {
|
||||||
|
const url = this.buildUrl(resource, identifier, queryParams);
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
'Authorization': `Ghost ${token}`,
|
||||||
|
'Accept-Version': this.acceptVersionHeader,
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'User-Agent': 'GhostAdminAPI/2.0'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestOptions: RequestInit = {
|
||||||
|
method,
|
||||||
|
headers
|
||||||
|
};
|
||||||
|
|
||||||
|
// Add body for POST/PUT
|
||||||
|
if (body && (method === 'POST' || method === 'PUT')) {
|
||||||
|
headers['Content-Type'] = 'application/json';
|
||||||
|
requestOptions.body = JSON.stringify(body);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, requestOptions);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
|
||||||
|
errors: [{ type: 'UnknownError', message: response.statusText }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const error = errorData.errors?.[0];
|
||||||
|
const err = new Error(error?.message || response.statusText);
|
||||||
|
Object.assign(err, {
|
||||||
|
name: error?.type || 'GhostAdminAPIError',
|
||||||
|
statusCode: response.status,
|
||||||
|
...error
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DELETE returns empty response
|
||||||
|
if (method === 'DELETE') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: IGhostAPIResponse<T> = await response.json();
|
||||||
|
|
||||||
|
// Extract the resource data
|
||||||
|
const resourceData = data[resource];
|
||||||
|
if (!resourceData) {
|
||||||
|
return data as any; // For some special endpoints that don't follow standard format
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array and has meta, attach meta to the array
|
||||||
|
if (Array.isArray(resourceData) && data.meta) {
|
||||||
|
return Object.assign(resourceData, { meta: data.meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array with single item and no meta, return the item
|
||||||
|
if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) {
|
||||||
|
return resourceData[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceData as T | T[];
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create resource API methods factory
|
||||||
|
*/
|
||||||
|
private createResourceAPI(resourceType: string) {
|
||||||
|
return {
|
||||||
|
browse: (options?: IBrowseOptions) => {
|
||||||
|
return this.makeRequest(resourceType, 'GET', undefined, undefined, options);
|
||||||
|
},
|
||||||
|
|
||||||
|
read: (data: { id?: string; slug?: string; email?: string }, queryParams?: Record<string, any>) => {
|
||||||
|
if (data.slug) {
|
||||||
|
return this.makeRequest(resourceType, 'GET', `slug/${data.slug}`, undefined, queryParams);
|
||||||
|
}
|
||||||
|
if (data.email) {
|
||||||
|
return this.makeRequest(resourceType, 'GET', `email/${data.email}`, undefined, queryParams);
|
||||||
|
}
|
||||||
|
if (data.id) {
|
||||||
|
return this.makeRequest(resourceType, 'GET', data.id, undefined, queryParams);
|
||||||
|
}
|
||||||
|
throw new Error('Must provide id, slug, or email');
|
||||||
|
},
|
||||||
|
|
||||||
|
add: (data: any, queryParams?: Record<string, any>) => {
|
||||||
|
if (!data || !Object.keys(data).length) {
|
||||||
|
return Promise.reject(new Error('Missing data'));
|
||||||
|
}
|
||||||
|
const body: any = {};
|
||||||
|
body[resourceType] = [data];
|
||||||
|
return this.makeRequest(resourceType, 'POST', undefined, body, queryParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
edit: (data: any, queryParams?: Record<string, any>) => {
|
||||||
|
if (!data) {
|
||||||
|
return Promise.reject(new Error('Missing data'));
|
||||||
|
}
|
||||||
|
if (!data.id) {
|
||||||
|
return Promise.reject(new Error('Must include data.id'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = data.id;
|
||||||
|
const updateData = { ...data };
|
||||||
|
delete updateData.id;
|
||||||
|
|
||||||
|
const body: any = {};
|
||||||
|
body[resourceType] = [updateData];
|
||||||
|
|
||||||
|
return this.makeRequest(resourceType, 'PUT', id, body, queryParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (data: { id?: string; email?: string }, queryParams?: Record<string, any>) => {
|
||||||
|
if (!data) {
|
||||||
|
return Promise.reject(new Error('Missing data'));
|
||||||
|
}
|
||||||
|
if (!data.id && !data.email) {
|
||||||
|
return Promise.reject(new Error('Must include either data.id or data.email'));
|
||||||
|
}
|
||||||
|
|
||||||
|
const identifier = data.email ? `email/${data.email}` : data.id;
|
||||||
|
return this.makeRequest(resourceType, 'DELETE', identifier, undefined, queryParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resource APIs
|
||||||
|
public posts = this.createResourceAPI('posts');
|
||||||
|
public pages = this.createResourceAPI('pages');
|
||||||
|
public tags = this.createResourceAPI('tags');
|
||||||
|
public members = this.createResourceAPI('members');
|
||||||
|
public users = this.createResourceAPI('users');
|
||||||
|
|
||||||
|
// Webhooks (limited operations)
|
||||||
|
public webhooks = {
|
||||||
|
add: (data: any, queryParams?: Record<string, any>) => {
|
||||||
|
const body: any = { webhooks: [data] };
|
||||||
|
return this.makeRequest('webhooks', 'POST', undefined, body, queryParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
edit: (data: any, queryParams?: Record<string, any>) => {
|
||||||
|
if (!data.id) {
|
||||||
|
return Promise.reject(new Error('Must include data.id'));
|
||||||
|
}
|
||||||
|
const id = data.id;
|
||||||
|
const updateData = { ...data };
|
||||||
|
delete updateData.id;
|
||||||
|
const body: any = { webhooks: [updateData] };
|
||||||
|
return this.makeRequest('webhooks', 'PUT', id, body, queryParams);
|
||||||
|
},
|
||||||
|
|
||||||
|
delete: (data: { id: string }, queryParams?: Record<string, any>) => {
|
||||||
|
if (!data.id) {
|
||||||
|
return Promise.reject(new Error('Must include data.id'));
|
||||||
|
}
|
||||||
|
return this.makeRequest('webhooks', 'DELETE', data.id, undefined, queryParams);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Image upload
|
||||||
|
public images = {
|
||||||
|
upload: async (data: { file: string }) => {
|
||||||
|
if (!data || !data.file) {
|
||||||
|
throw new Error('Must provide file path');
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = this.buildUrl('images', 'upload');
|
||||||
|
const token = await this.getAuthToken();
|
||||||
|
|
||||||
|
// Read file
|
||||||
|
const fileBuffer = fs.readFileSync(data.file);
|
||||||
|
const fileName = path.basename(data.file);
|
||||||
|
|
||||||
|
// Create FormData
|
||||||
|
const formData = new FormData();
|
||||||
|
// Convert Buffer to ArrayBuffer for Blob
|
||||||
|
const arrayBuffer = fileBuffer.buffer.slice(fileBuffer.byteOffset, fileBuffer.byteOffset + fileBuffer.byteLength) as ArrayBuffer;
|
||||||
|
const blob = new Blob([arrayBuffer], { type: 'image/*' });
|
||||||
|
formData.append('file', blob, fileName);
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Authorization': `Ghost ${token}`,
|
||||||
|
'Accept-Version': this.acceptVersionHeader,
|
||||||
|
'User-Agent': 'GhostAdminAPI/2.0'
|
||||||
|
},
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
|
||||||
|
errors: [{ type: 'UnknownError', message: response.statusText }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const error = errorData.errors?.[0];
|
||||||
|
const err = new Error(error?.message || response.statusText);
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await response.json();
|
||||||
|
return result.images?.[0] || result;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
191
ts/apiclient/ghost.contentapi.ts
Normal file
191
ts/apiclient/ghost.contentapi.ts
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
/**
|
||||||
|
* Ghost Content API Client
|
||||||
|
* Read-only API for published content using native fetch
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type { IBrowseOptions, IReadOptions, IGhostAPIResponse, IGhostErrorResponse } from './ghost.types.js';
|
||||||
|
|
||||||
|
export interface IGhostContentAPIOptions {
|
||||||
|
url: string;
|
||||||
|
key: string;
|
||||||
|
version?: string;
|
||||||
|
ghostPath?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class GhostContentAPI {
|
||||||
|
private url: string;
|
||||||
|
private key: string;
|
||||||
|
private version: string;
|
||||||
|
private ghostPath: string;
|
||||||
|
|
||||||
|
constructor(options: IGhostContentAPIOptions) {
|
||||||
|
this.url = options.url.replace(/\/$/, ''); // Remove trailing slash
|
||||||
|
this.key = options.key;
|
||||||
|
this.version = options.version || 'v3';
|
||||||
|
this.ghostPath = options.ghostPath || 'ghost';
|
||||||
|
|
||||||
|
if (!this.url) {
|
||||||
|
throw new Error('GhostContentAPI: url is required');
|
||||||
|
}
|
||||||
|
if (!this.key) {
|
||||||
|
throw new Error('GhostContentAPI: key is required');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the API prefix based on version
|
||||||
|
*/
|
||||||
|
private getAPIPrefix(): string {
|
||||||
|
// v5+ doesn't need version prefix
|
||||||
|
if (this.version === 'v5' || this.version === 'v6' || this.version.match(/^v[5-9]\.\d+/)) {
|
||||||
|
return `/content/`;
|
||||||
|
}
|
||||||
|
// v2-v4 and canary need version prefix
|
||||||
|
return `/${this.version}/content/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build full API URL
|
||||||
|
*/
|
||||||
|
private buildUrl(resource: string, identifier?: string, params?: Record<string, any>): string {
|
||||||
|
const apiPrefix = this.getAPIPrefix();
|
||||||
|
let endpoint = `${this.url}/${this.ghostPath}/api${apiPrefix}${resource}/`;
|
||||||
|
|
||||||
|
if (identifier) {
|
||||||
|
endpoint += `${identifier}/`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add key to params
|
||||||
|
const queryParams = {
|
||||||
|
key: this.key,
|
||||||
|
...params
|
||||||
|
};
|
||||||
|
|
||||||
|
// Build query string
|
||||||
|
const queryString = Object.keys(queryParams)
|
||||||
|
.filter(key => queryParams[key] !== undefined && queryParams[key] !== null)
|
||||||
|
.map(key => {
|
||||||
|
const value = Array.isArray(queryParams[key])
|
||||||
|
? queryParams[key].join(',')
|
||||||
|
: queryParams[key];
|
||||||
|
return `${key}=${encodeURIComponent(value)}`;
|
||||||
|
})
|
||||||
|
.join('&');
|
||||||
|
|
||||||
|
return queryString ? `${endpoint}?${queryString}` : endpoint;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Make API request
|
||||||
|
*/
|
||||||
|
private async makeRequest<T>(
|
||||||
|
resource: string,
|
||||||
|
identifier?: string,
|
||||||
|
params?: Record<string, any>
|
||||||
|
): Promise<T | T[]> {
|
||||||
|
const url = this.buildUrl(resource, identifier, params);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'application/json',
|
||||||
|
'Accept-Version': this.version.match(/^\d+\.\d+$/) ? `v${this.version}` : this.version,
|
||||||
|
'User-Agent': 'GhostContentAPI/2.0'
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const errorData: IGhostErrorResponse = await response.json().catch(() => ({
|
||||||
|
errors: [{ type: 'UnknownError', message: response.statusText }]
|
||||||
|
}));
|
||||||
|
|
||||||
|
const error = errorData.errors?.[0];
|
||||||
|
const err = new Error(error?.message || response.statusText);
|
||||||
|
Object.assign(err, {
|
||||||
|
name: error?.type || 'GhostContentAPIError',
|
||||||
|
statusCode: response.status,
|
||||||
|
...error
|
||||||
|
});
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: IGhostAPIResponse<T> = await response.json();
|
||||||
|
|
||||||
|
// Extract the resource data
|
||||||
|
const resourceData = data[resource];
|
||||||
|
if (!resourceData) {
|
||||||
|
throw new Error(`Response missing ${resource} property`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array and has meta, attach meta to the array
|
||||||
|
if (Array.isArray(resourceData) && data.meta) {
|
||||||
|
return Object.assign(resourceData, { meta: data.meta });
|
||||||
|
}
|
||||||
|
|
||||||
|
// If it's an array with single item and no meta, return the item
|
||||||
|
if (Array.isArray(resourceData) && resourceData.length === 1 && !data.meta) {
|
||||||
|
return resourceData[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
return resourceData as T | T[];
|
||||||
|
} catch (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create resource API methods
|
||||||
|
*/
|
||||||
|
public posts = {
|
||||||
|
browse: (options?: IBrowseOptions) => this.makeRequest('posts', undefined, options),
|
||||||
|
read: (options: IReadOptions) => {
|
||||||
|
if (options.slug) {
|
||||||
|
return this.makeRequest('posts', `slug/${options.slug}`, options);
|
||||||
|
}
|
||||||
|
if (options.id) {
|
||||||
|
return this.makeRequest('posts', options.id, options);
|
||||||
|
}
|
||||||
|
throw new Error('Must provide id or slug');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public pages = {
|
||||||
|
browse: (options?: IBrowseOptions) => this.makeRequest('pages', undefined, options),
|
||||||
|
read: (options: IReadOptions) => {
|
||||||
|
if (options.slug) {
|
||||||
|
return this.makeRequest('pages', `slug/${options.slug}`, options);
|
||||||
|
}
|
||||||
|
if (options.id) {
|
||||||
|
return this.makeRequest('pages', options.id, options);
|
||||||
|
}
|
||||||
|
throw new Error('Must provide id or slug');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public tags = {
|
||||||
|
browse: (options?: IBrowseOptions) => this.makeRequest('tags', undefined, options),
|
||||||
|
read: (options: IReadOptions) => {
|
||||||
|
if (options.slug) {
|
||||||
|
return this.makeRequest('tags', `slug/${options.slug}`, options);
|
||||||
|
}
|
||||||
|
if (options.id) {
|
||||||
|
return this.makeRequest('tags', options.id, options);
|
||||||
|
}
|
||||||
|
throw new Error('Must provide id or slug');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
public authors = {
|
||||||
|
browse: (options?: IBrowseOptions) => this.makeRequest('authors', undefined, options),
|
||||||
|
read: (options: IReadOptions) => {
|
||||||
|
if (options.slug) {
|
||||||
|
return this.makeRequest('authors', `slug/${options.slug}`, options);
|
||||||
|
}
|
||||||
|
if (options.id) {
|
||||||
|
return this.makeRequest('authors', options.id, options);
|
||||||
|
}
|
||||||
|
throw new Error('Must provide id or slug');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
116
ts/apiclient/ghost.jwt.ts
Normal file
116
ts/apiclient/ghost.jwt.ts
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
/**
|
||||||
|
* JWT token generator for Ghost Admin API
|
||||||
|
* Implements HS256 signing compatible with Ghost's authentication
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base64 URL encode (without padding)
|
||||||
|
*/
|
||||||
|
function base64UrlEncode(data: Uint8Array): string {
|
||||||
|
const base64 = typeof Buffer !== 'undefined'
|
||||||
|
? Buffer.from(data).toString('base64')
|
||||||
|
: btoa(String.fromCharCode(...data));
|
||||||
|
|
||||||
|
return base64
|
||||||
|
.replace(/\+/g, '-')
|
||||||
|
.replace(/\//g, '_')
|
||||||
|
.replace(/=/g, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert hex string to Uint8Array
|
||||||
|
*/
|
||||||
|
function hexToBytes(hex: string): Uint8Array {
|
||||||
|
const bytes = new Uint8Array(hex.length / 2);
|
||||||
|
for (let i = 0; i < hex.length; i += 2) {
|
||||||
|
bytes[i / 2] = parseInt(hex.substring(i, i + 2), 16);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate JWT token for Ghost Admin API
|
||||||
|
* @param key - Admin API key in format {id}:{secret}
|
||||||
|
* @param audience - Token audience (API prefix like '/admin/')
|
||||||
|
* @returns JWT token string
|
||||||
|
*/
|
||||||
|
export async function generateToken(key: string, audience: string): Promise<string> {
|
||||||
|
// Parse the admin key
|
||||||
|
const [keyId, secret] = key.split(':');
|
||||||
|
|
||||||
|
if (!keyId || !secret) {
|
||||||
|
throw new Error('Invalid admin API key format. Expected {id}:{secret}');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (keyId.length !== 24 || secret.length !== 64) {
|
||||||
|
throw new Error('Invalid admin API key format. Expected 24 hex chars for id and 64 for secret');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create JWT header
|
||||||
|
const header = {
|
||||||
|
alg: 'HS256',
|
||||||
|
typ: 'JWT',
|
||||||
|
kid: keyId
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create JWT payload
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
const payload = {
|
||||||
|
iat: now,
|
||||||
|
exp: now + 300, // 5 minutes
|
||||||
|
aud: audience
|
||||||
|
};
|
||||||
|
|
||||||
|
// Encode header and payload
|
||||||
|
const headerEncoded = base64UrlEncode(
|
||||||
|
new TextEncoder().encode(JSON.stringify(header))
|
||||||
|
);
|
||||||
|
const payloadEncoded = base64UrlEncode(
|
||||||
|
new TextEncoder().encode(JSON.stringify(payload))
|
||||||
|
);
|
||||||
|
|
||||||
|
// Create signature data
|
||||||
|
const signatureData = `${headerEncoded}.${payloadEncoded}`;
|
||||||
|
|
||||||
|
// Convert secret from hex to bytes
|
||||||
|
const secretBytes = hexToBytes(secret);
|
||||||
|
|
||||||
|
// Import key for HMAC
|
||||||
|
let cryptoKey: CryptoKey;
|
||||||
|
|
||||||
|
// Try to use Web Crypto API (works in Node 15+ and all modern browsers)
|
||||||
|
try {
|
||||||
|
const crypto = globalThis.crypto || (await import('crypto')).webcrypto;
|
||||||
|
// Convert to proper BufferSource type
|
||||||
|
const secretBuffer = secretBytes.buffer.slice(secretBytes.byteOffset, secretBytes.byteOffset + secretBytes.byteLength) as ArrayBuffer;
|
||||||
|
cryptoKey = await crypto.subtle.importKey(
|
||||||
|
'raw',
|
||||||
|
secretBuffer,
|
||||||
|
{ name: 'HMAC', hash: 'SHA-256' },
|
||||||
|
false,
|
||||||
|
['sign']
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign the data
|
||||||
|
const signature = await crypto.subtle.sign(
|
||||||
|
'HMAC',
|
||||||
|
cryptoKey,
|
||||||
|
new TextEncoder().encode(signatureData)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Encode signature
|
||||||
|
const signatureEncoded = base64UrlEncode(new Uint8Array(signature));
|
||||||
|
|
||||||
|
// Return complete JWT
|
||||||
|
return `${signatureData}.${signatureEncoded}`;
|
||||||
|
} catch (error) {
|
||||||
|
// Fallback for older Node versions using crypto module directly
|
||||||
|
const crypto = await import('crypto');
|
||||||
|
const hmac = crypto.createHmac('sha256', secretBytes);
|
||||||
|
hmac.update(signatureData);
|
||||||
|
const signature = hmac.digest();
|
||||||
|
const signatureEncoded = base64UrlEncode(signature);
|
||||||
|
|
||||||
|
return `${signatureData}.${signatureEncoded}`;
|
||||||
|
}
|
||||||
|
}
|
66
ts/apiclient/ghost.types.ts
Normal file
66
ts/apiclient/ghost.types.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
/**
|
||||||
|
* Shared types for Ghost API clients
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface IGhostAPIResponse<T> {
|
||||||
|
[key: string]: T | T[] | IGhostMeta;
|
||||||
|
meta?: IGhostMeta;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGhostMeta {
|
||||||
|
pagination?: {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
pages: number;
|
||||||
|
total: number;
|
||||||
|
next: number | null;
|
||||||
|
prev: number | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGhostError {
|
||||||
|
type: string;
|
||||||
|
message: string;
|
||||||
|
context?: string;
|
||||||
|
property?: string;
|
||||||
|
help?: string;
|
||||||
|
code?: string;
|
||||||
|
id?: string;
|
||||||
|
ghostErrorCode?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IGhostErrorResponse {
|
||||||
|
errors: IGhostError[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type THttpMethod = 'GET' | 'POST' | 'PUT' | 'DELETE';
|
||||||
|
|
||||||
|
export interface IRequestOptions {
|
||||||
|
method?: THttpMethod;
|
||||||
|
headers?: Record<string, string>;
|
||||||
|
body?: string;
|
||||||
|
signal?: AbortSignal;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Query parameters for browse operations
|
||||||
|
*/
|
||||||
|
export interface IBrowseOptions {
|
||||||
|
limit?: number;
|
||||||
|
page?: number;
|
||||||
|
filter?: string;
|
||||||
|
include?: string;
|
||||||
|
fields?: string;
|
||||||
|
order?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Options for read operations (by ID, slug, or email)
|
||||||
|
*/
|
||||||
|
export interface IReadOptions {
|
||||||
|
id?: string;
|
||||||
|
slug?: string;
|
||||||
|
email?: string;
|
||||||
|
include?: string;
|
||||||
|
fields?: string;
|
||||||
|
}
|
@@ -22,13 +22,13 @@ export class Ghost {
|
|||||||
this.adminApi = new plugins.GhostAdminAPI({
|
this.adminApi = new plugins.GhostAdminAPI({
|
||||||
url: this.options.baseUrl,
|
url: this.options.baseUrl,
|
||||||
key: this.options.adminApiKey,
|
key: this.options.adminApiKey,
|
||||||
version: 'v3',
|
version: 'v6.0',
|
||||||
});
|
});
|
||||||
|
|
||||||
this.contentApi = new plugins.GhostContentAPI({
|
this.contentApi = new plugins.GhostContentAPI({
|
||||||
url: this.options.baseUrl,
|
url: this.options.baseUrl,
|
||||||
key: this.options.contentApiKey,
|
key: this.options.contentApiKey,
|
||||||
version: 'v3',
|
version: 'v6.0',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,22 +95,50 @@ export class Ghost {
|
|||||||
return new Post(this, postData);
|
return new Post(this, postData);
|
||||||
}
|
}
|
||||||
|
|
||||||
public async getTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> {
|
public async getTags(optionsArg?: {
|
||||||
|
filter?: string;
|
||||||
|
limit?: number;
|
||||||
|
visibility?: 'public' | 'internal' | 'all';
|
||||||
|
include?: string;
|
||||||
|
}): Promise<ITag[]> {
|
||||||
try {
|
try {
|
||||||
const limit = optionsArg?.limit || 1000;
|
const limit = optionsArg?.limit || 1000;
|
||||||
const tagsData = await this.contentApi.tags.browse({ limit });
|
const visibility = optionsArg?.visibility || 'all';
|
||||||
|
|
||||||
|
// Use Admin API to get ALL tags including those with zero posts
|
||||||
|
const browseOptions: any = { limit };
|
||||||
|
|
||||||
|
// Add visibility filter if not 'all'
|
||||||
|
if (visibility !== 'all') {
|
||||||
|
browseOptions.filter = `visibility:${visibility}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (optionsArg?.include) {
|
||||||
|
browseOptions.include = optionsArg.include;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tagsData = await this.adminApi.tags.browse(browseOptions);
|
||||||
|
|
||||||
|
// Apply minimatch filter if provided
|
||||||
if (optionsArg?.filter) {
|
if (optionsArg?.filter) {
|
||||||
const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter);
|
const matcher = new plugins.smartmatch.SmartMatch(optionsArg.filter);
|
||||||
return tagsData.filter((tag: ITag) => matcher.match(tag.slug));
|
return tagsData.filter((tag: ITag) => matcher.match(tag.slug));
|
||||||
}
|
}
|
||||||
|
|
||||||
return tagsData;
|
return tagsData;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async getPublicTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> {
|
||||||
|
return this.getTags({ ...optionsArg, visibility: 'public' });
|
||||||
|
}
|
||||||
|
|
||||||
|
public async getInternalTags(optionsArg?: { filter?: string; limit?: number }): Promise<ITag[]> {
|
||||||
|
return this.getTags({ ...optionsArg, visibility: 'internal' });
|
||||||
|
}
|
||||||
|
|
||||||
public async getTagById(id: string): Promise<Tag> {
|
public async getTagById(id: string): Promise<Tag> {
|
||||||
try {
|
try {
|
||||||
const tagData = await this.contentApi.tags.read({ id });
|
const tagData = await this.contentApi.tags.read({ id });
|
||||||
|
406
ts/classes.syncedinstance.ts
Normal file
406
ts/classes.syncedinstance.ts
Normal file
@@ -0,0 +1,406 @@
|
|||||||
|
import * as plugins from './ghost.plugins.js';
|
||||||
|
import { Ghost } from './classes.ghost.js';
|
||||||
|
import { type IPost } from './classes.post.js';
|
||||||
|
import { type IPage } from './classes.page.js';
|
||||||
|
import { type ITag } from './classes.post.js';
|
||||||
|
|
||||||
|
export interface ISyncOptions {
|
||||||
|
incremental?: boolean;
|
||||||
|
filter?: string;
|
||||||
|
dryRun?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISyncItemResult {
|
||||||
|
sourceId: string;
|
||||||
|
sourceSlug: string;
|
||||||
|
targetId?: string;
|
||||||
|
status: 'created' | 'updated' | 'skipped' | 'failed';
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISyncTargetReport {
|
||||||
|
targetUrl: string;
|
||||||
|
results: ISyncItemResult[];
|
||||||
|
successCount: number;
|
||||||
|
failureCount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISyncReport {
|
||||||
|
contentType: 'posts' | 'pages' | 'tags';
|
||||||
|
totalItems: number;
|
||||||
|
targetReports: ISyncTargetReport[];
|
||||||
|
duration: number;
|
||||||
|
timestamp: Date;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ISyncMapping {
|
||||||
|
sourceId: string;
|
||||||
|
sourceSlug: string;
|
||||||
|
targetMappings: Array<{
|
||||||
|
targetUrl: string;
|
||||||
|
targetId: string;
|
||||||
|
lastSynced: Date;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class SyncedInstance {
|
||||||
|
public sourceGhost: Ghost;
|
||||||
|
public targetGhosts: Ghost[];
|
||||||
|
private syncMappings: Map<string, ISyncMapping>;
|
||||||
|
private syncHistory: ISyncReport[];
|
||||||
|
|
||||||
|
constructor(sourceGhost: Ghost, targetGhosts: Ghost[]) {
|
||||||
|
this.sourceGhost = sourceGhost;
|
||||||
|
this.targetGhosts = targetGhosts;
|
||||||
|
this.syncMappings = new Map();
|
||||||
|
this.syncHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
private addMapping(contentType: string, sourceId: string, sourceSlug: string, targetUrl: string, targetId: string) {
|
||||||
|
const key = `${contentType}:${sourceId}`;
|
||||||
|
const existing = this.syncMappings.get(key);
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
const targetMapping = existing.targetMappings.find(tm => tm.targetUrl === targetUrl);
|
||||||
|
if (targetMapping) {
|
||||||
|
targetMapping.targetId = targetId;
|
||||||
|
targetMapping.lastSynced = new Date();
|
||||||
|
} else {
|
||||||
|
existing.targetMappings.push({
|
||||||
|
targetUrl,
|
||||||
|
targetId,
|
||||||
|
lastSynced: new Date()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.syncMappings.set(key, {
|
||||||
|
sourceId,
|
||||||
|
sourceSlug,
|
||||||
|
targetMappings: [{
|
||||||
|
targetUrl,
|
||||||
|
targetId,
|
||||||
|
lastSynced: new Date()
|
||||||
|
}]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMapping(contentType: string, sourceId: string, targetUrl: string): string | undefined {
|
||||||
|
const key = `${contentType}:${sourceId}`;
|
||||||
|
const mapping = this.syncMappings.get(key);
|
||||||
|
if (!mapping) return undefined;
|
||||||
|
|
||||||
|
const targetMapping = mapping.targetMappings.find(tm => tm.targetUrl === targetUrl);
|
||||||
|
return targetMapping?.targetId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncTags(optionsArg?: ISyncOptions): Promise<ISyncReport> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const report: ISyncReport = {
|
||||||
|
contentType: 'tags',
|
||||||
|
totalItems: 0,
|
||||||
|
targetReports: [],
|
||||||
|
duration: 0,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourceTags = await this.sourceGhost.getTags(optionsArg?.filter ? { filter: optionsArg.filter } : {});
|
||||||
|
report.totalItems = sourceTags.length;
|
||||||
|
|
||||||
|
for (const targetGhost of this.targetGhosts) {
|
||||||
|
const targetReport: ISyncTargetReport = {
|
||||||
|
targetUrl: targetGhost.options.baseUrl,
|
||||||
|
results: [],
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sourceTag of sourceTags) {
|
||||||
|
try {
|
||||||
|
let targetTag: any;
|
||||||
|
let status: 'created' | 'updated' | 'skipped' = 'created';
|
||||||
|
|
||||||
|
try {
|
||||||
|
targetTag = await targetGhost.getTagBySlug(sourceTag.slug);
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
await targetTag.update({
|
||||||
|
name: sourceTag.name,
|
||||||
|
description: sourceTag.description,
|
||||||
|
feature_image: sourceTag.feature_image,
|
||||||
|
visibility: sourceTag.visibility,
|
||||||
|
meta_title: sourceTag.meta_title,
|
||||||
|
meta_description: sourceTag.meta_description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
status = 'updated';
|
||||||
|
} catch (error) {
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
targetTag = await targetGhost.createTag({
|
||||||
|
name: sourceTag.name,
|
||||||
|
slug: sourceTag.slug,
|
||||||
|
description: sourceTag.description,
|
||||||
|
feature_image: sourceTag.feature_image,
|
||||||
|
visibility: sourceTag.visibility,
|
||||||
|
meta_title: sourceTag.meta_title,
|
||||||
|
meta_description: sourceTag.meta_description
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!optionsArg?.dryRun && targetTag) {
|
||||||
|
this.addMapping('tags', sourceTag.id, sourceTag.slug, targetGhost.options.baseUrl, targetTag.tagData.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: sourceTag.id,
|
||||||
|
sourceSlug: sourceTag.slug,
|
||||||
|
targetId: targetTag?.tagData?.id,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
targetReport.successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: sourceTag.id,
|
||||||
|
sourceSlug: sourceTag.slug,
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
targetReport.failureCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.targetReports.push(targetReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
report.duration = Date.now() - startTime;
|
||||||
|
this.syncHistory.push(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncPosts(optionsArg?: ISyncOptions): Promise<ISyncReport> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const report: ISyncReport = {
|
||||||
|
contentType: 'posts',
|
||||||
|
totalItems: 0,
|
||||||
|
targetReports: [],
|
||||||
|
duration: 0,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourcePosts = await this.sourceGhost.getPosts(optionsArg?.filter ? { filter: optionsArg.filter } : {});
|
||||||
|
report.totalItems = sourcePosts.length;
|
||||||
|
|
||||||
|
for (const targetGhost of this.targetGhosts) {
|
||||||
|
const targetReport: ISyncTargetReport = {
|
||||||
|
targetUrl: targetGhost.options.baseUrl,
|
||||||
|
results: [],
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sourcePost of sourcePosts) {
|
||||||
|
try {
|
||||||
|
const postData = sourcePost.postData;
|
||||||
|
|
||||||
|
const tagSlugs = postData.tags?.map(t => t.slug) || [];
|
||||||
|
const targetTagIds: string[] = [];
|
||||||
|
|
||||||
|
for (const tagSlug of tagSlugs) {
|
||||||
|
try {
|
||||||
|
const targetTag = await targetGhost.getTagBySlug(tagSlug);
|
||||||
|
targetTagIds.push(targetTag.tagData.id);
|
||||||
|
} catch (error) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncData: any = {
|
||||||
|
title: postData.title,
|
||||||
|
slug: postData.slug,
|
||||||
|
html: postData.html,
|
||||||
|
feature_image: postData.feature_image,
|
||||||
|
featured: postData.featured,
|
||||||
|
status: postData.status,
|
||||||
|
visibility: postData.visibility,
|
||||||
|
meta_title: postData.meta_title,
|
||||||
|
meta_description: postData.meta_description,
|
||||||
|
published_at: postData.published_at,
|
||||||
|
custom_excerpt: postData.custom_excerpt,
|
||||||
|
tags: targetTagIds.length > 0 ? targetTagIds.map(id => ({ id })) : undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
let targetPost: any;
|
||||||
|
let status: 'created' | 'updated' = 'created';
|
||||||
|
|
||||||
|
try {
|
||||||
|
targetPost = await targetGhost.contentApi.posts.read({ slug: postData.slug }, { formats: ['html'] });
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
const updated = await targetGhost.adminApi.posts.edit({
|
||||||
|
...syncData,
|
||||||
|
id: targetPost.id,
|
||||||
|
updated_at: targetPost.updated_at
|
||||||
|
});
|
||||||
|
targetPost = updated;
|
||||||
|
}
|
||||||
|
status = 'updated';
|
||||||
|
} catch (error) {
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
targetPost = await targetGhost.adminApi.posts.add(syncData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!optionsArg?.dryRun && targetPost) {
|
||||||
|
this.addMapping('posts', postData.id, postData.slug, targetGhost.options.baseUrl, targetPost.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: postData.id,
|
||||||
|
sourceSlug: postData.slug,
|
||||||
|
targetId: targetPost?.id,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
targetReport.successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: sourcePost.postData.id,
|
||||||
|
sourceSlug: sourcePost.postData.slug,
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
targetReport.failureCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.targetReports.push(targetReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
report.duration = Date.now() - startTime;
|
||||||
|
this.syncHistory.push(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncPages(optionsArg?: ISyncOptions): Promise<ISyncReport> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
const report: ISyncReport = {
|
||||||
|
contentType: 'pages',
|
||||||
|
totalItems: 0,
|
||||||
|
targetReports: [],
|
||||||
|
duration: 0,
|
||||||
|
timestamp: new Date()
|
||||||
|
};
|
||||||
|
|
||||||
|
const sourcePages = await this.sourceGhost.getPages(optionsArg?.filter ? { filter: optionsArg.filter } : {});
|
||||||
|
report.totalItems = sourcePages.length;
|
||||||
|
|
||||||
|
for (const targetGhost of this.targetGhosts) {
|
||||||
|
const targetReport: ISyncTargetReport = {
|
||||||
|
targetUrl: targetGhost.options.baseUrl,
|
||||||
|
results: [],
|
||||||
|
successCount: 0,
|
||||||
|
failureCount: 0
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const sourcePage of sourcePages) {
|
||||||
|
try {
|
||||||
|
const pageData = sourcePage.pageData;
|
||||||
|
|
||||||
|
const syncData: Partial<IPage> = {
|
||||||
|
title: pageData.title,
|
||||||
|
slug: pageData.slug,
|
||||||
|
html: pageData.html,
|
||||||
|
feature_image: pageData.feature_image,
|
||||||
|
featured: pageData.featured,
|
||||||
|
status: pageData.status,
|
||||||
|
visibility: pageData.visibility,
|
||||||
|
meta_title: pageData.meta_title,
|
||||||
|
meta_description: pageData.meta_description,
|
||||||
|
published_at: pageData.published_at,
|
||||||
|
custom_excerpt: pageData.custom_excerpt
|
||||||
|
};
|
||||||
|
|
||||||
|
let targetPage: any;
|
||||||
|
let status: 'created' | 'updated' = 'created';
|
||||||
|
|
||||||
|
try {
|
||||||
|
targetPage = await targetGhost.contentApi.pages.read({ slug: pageData.slug }, { formats: ['html'] });
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
const updated = await targetGhost.adminApi.pages.edit({
|
||||||
|
...syncData,
|
||||||
|
id: targetPage.id,
|
||||||
|
updated_at: targetPage.updated_at
|
||||||
|
});
|
||||||
|
targetPage = updated;
|
||||||
|
}
|
||||||
|
status = 'updated';
|
||||||
|
} catch (error) {
|
||||||
|
if (!optionsArg?.dryRun) {
|
||||||
|
targetPage = await targetGhost.adminApi.pages.add(syncData);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!optionsArg?.dryRun && targetPage) {
|
||||||
|
this.addMapping('pages', pageData.id, pageData.slug, targetGhost.options.baseUrl, targetPage.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: pageData.id,
|
||||||
|
sourceSlug: pageData.slug,
|
||||||
|
targetId: targetPage?.id,
|
||||||
|
status
|
||||||
|
});
|
||||||
|
targetReport.successCount++;
|
||||||
|
} catch (error) {
|
||||||
|
targetReport.results.push({
|
||||||
|
sourceId: sourcePage.pageData.id,
|
||||||
|
sourceSlug: sourcePage.pageData.slug,
|
||||||
|
status: 'failed',
|
||||||
|
error: error instanceof Error ? error.message : String(error)
|
||||||
|
});
|
||||||
|
targetReport.failureCount++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
report.targetReports.push(targetReport);
|
||||||
|
}
|
||||||
|
|
||||||
|
report.duration = Date.now() - startTime;
|
||||||
|
this.syncHistory.push(report);
|
||||||
|
return report;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async syncAll(optionsArg?: { types?: Array<'posts' | 'pages' | 'tags'>; syncOptions?: ISyncOptions }): Promise<ISyncReport[]> {
|
||||||
|
const types = optionsArg?.types || ['tags', 'posts', 'pages'];
|
||||||
|
const reports: ISyncReport[] = [];
|
||||||
|
|
||||||
|
for (const type of types) {
|
||||||
|
if (type === 'tags') {
|
||||||
|
reports.push(await this.syncTags(optionsArg?.syncOptions));
|
||||||
|
} else if (type === 'posts') {
|
||||||
|
reports.push(await this.syncPosts(optionsArg?.syncOptions));
|
||||||
|
} else if (type === 'pages') {
|
||||||
|
reports.push(await this.syncPages(optionsArg?.syncOptions));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return reports;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getSyncStatus(): {
|
||||||
|
totalMappings: number;
|
||||||
|
mappings: ISyncMapping[];
|
||||||
|
recentSyncs: ISyncReport[];
|
||||||
|
} {
|
||||||
|
return {
|
||||||
|
totalMappings: this.syncMappings.size,
|
||||||
|
mappings: Array.from(this.syncMappings.values()),
|
||||||
|
recentSyncs: this.syncHistory.slice(-10)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearSyncHistory(): void {
|
||||||
|
this.syncHistory = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
public clearMappings(): void {
|
||||||
|
this.syncMappings.clear();
|
||||||
|
}
|
||||||
|
}
|
@@ -26,6 +26,18 @@ export class Tag {
|
|||||||
return this.tagData.description;
|
return this.tagData.description;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public getVisibility(): string {
|
||||||
|
return this.tagData.visibility;
|
||||||
|
}
|
||||||
|
|
||||||
|
public isInternal(): boolean {
|
||||||
|
return this.tagData.visibility === 'internal';
|
||||||
|
}
|
||||||
|
|
||||||
|
public isPublic(): boolean {
|
||||||
|
return this.tagData.visibility === 'public';
|
||||||
|
}
|
||||||
|
|
||||||
public toJson(): ITag {
|
public toJson(): ITag {
|
||||||
return this.tagData;
|
return this.tagData;
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import GhostContentAPI from '@tryghost/content-api';
|
import { GhostContentAPI } from './apiclient/ghost.contentapi.js';
|
||||||
import GhostAdminAPI from '@tryghost/admin-api';
|
import { GhostAdminAPI } from './apiclient/ghost.adminapi.js';
|
||||||
import * as smartmatch from '@push.rocks/smartmatch';
|
import * as smartmatch from '@push.rocks/smartmatch';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
|
@@ -3,4 +3,5 @@ export * from './classes.post.js';
|
|||||||
export * from './classes.author.js';
|
export * from './classes.author.js';
|
||||||
export * from './classes.tag.js';
|
export * from './classes.tag.js';
|
||||||
export * from './classes.page.js';
|
export * from './classes.page.js';
|
||||||
export * from './classes.member.js';
|
export * from './classes.member.js';
|
||||||
|
export * from './classes.syncedinstance.js';
|
Reference in New Issue
Block a user