diff --git a/README.md b/README.md index 695f7c5..f140faa 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # NotionToRoblox > **Make Notion the single source of truth for Roblox monetization assets.** -> Sync Developer Products, Game Passes, and Badges from Notion databases to Roblox via the Open Cloud API. +> Sync Developer Products, Game Passes, Badges, and free Open Cloud Assets from Notion databases to Roblox. [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](./LICENSE) [![Rokit](https://img.shields.io/badge/install-Rokit-0078D4?logo=roblox)](https://github.com/rojo-rbx/rokit) @@ -10,7 +10,7 @@ ## Overview -NotionToRoblox is a CLI that treats Notion databases as the source of truth and synchronizes **Developer Products**, **Game Passes**, and **Badges** to a Roblox universe through the Open Cloud API. Create and update rows in Notion; the tool creates or updates matching Roblox assets and writes results back to Notion. +NotionToRoblox is a CLI that treats Notion databases as the source of truth and synchronizes **Developer Products**, **Game Passes**, **Badges**, and **Assets** (Animation, Audio, Decal, Image, Model) to Roblox through the Open Cloud API. Edit rows in Notion; run `sync` to create missing Roblox items and `update` to push current Notion fields onto existing ones. Results are written back to Notion. **Distribution:** Distributed via [Rokit](https://github.com/rojo-rbx/rokit) / [GitHub Releases](https://github.com/Zac134/NotionToRoblox/releases). Not published to npm (`private: true` in `package.json`). @@ -18,31 +18,27 @@ NotionToRoblox is a CLI that treats Notion databases as the source of truth and - [Rokit](https://github.com/rojo-rbx/rokit) - Notion Internal Integration with access to your parent page and databases (see [Notion setup](#notion-setup)) -- Roblox Creator Dashboard API Key for your universe (see [Roblox setup](#roblox-setup); required for `sync`, not for `init` / `create-databases`) +- Roblox Creator Dashboard API Key (see [Roblox setup](#roblox-setup); required for `sync` / `update`, not for `init` / `create-db`) --- ## Quick start -NotionToRoblox is distributed for [Rokit](https://github.com/rojo-rbx/rokit), the toolchain manager for Roblox projects. - ```bash -# From your Roblox project root rokit add Zac134/NotionToRoblox ntn-roblox rokit install ``` -The second argument (`ntn-roblox`) is the command name on `PATH`. Without it, Rokit exposes the tool as `NotionToRoblox`. - -Or add it to your project's `rokit.toml`: +Or in `rokit.toml`: ```toml [tools] -ntn-roblox = "Zac134/NotionToRoblox@0.1.1" +ntn-roblox = "Zac134/NotionToRoblox@0.2.0" ``` ```bash rokit install +ntn-roblox --version ntn-roblox sync --help ``` @@ -52,404 +48,231 @@ ntn-roblox sync --help ntn-roblox init ``` -Creates `.env` and `ntn-roblox.toml` in the current directory (skips files that already exist; pass `--force` to overwrite). Both are read from `process.cwd()`, so run commands from the directory that contains them (typically your Roblox project root). - #### First-time workflow 1. Run `ntn-roblox init`. -2. Create a Notion integration and set `NOTION_TOKEN` in `.env` (see [Notion setup](#notion-setup)). -3. Pick a **parent page** in Notion (existing page or new empty page) and **share it with the integration**. -4. Set `notion.parent_page_id` in `ntn-roblox.toml` (or pass `--parent-page-id` to the next step). -5. Run `ntn-roblox create-databases`. This creates the three databases and writes their IDs into `ntn-roblox.toml`. -6. Complete **Roblox setup** — set `ROBLOX_API_KEY` in `.env` and `roblox.universe_id` in `ntn-roblox.toml`. -7. Run `ntn-roblox sync`. - - -
-Notion setup — integration, parent page, and databases - -1. **Create an integration** at [notion.so/my-integrations](https://www.notion.so/my-integrations). Choose **Internal** integration. - -2. **Copy the Internal Integration Secret** into `.env` as `NOTION_TOKEN`. - -3. **Capabilities** (integration settings → Capabilities): - - **Read content** — required for `sync` (fetch rows from databases) - - **Update content** — required for `sync` (write back `Roblox ID`, `Sync Status`, `Sync Error`, and `Last Synced At`) - - **Insert content** — required for `create-databases` (creates the three databases under your parent page) - - For `sync` only, **Insert content** is not required unless you create rows manually outside Notion. - -4. **Connect the parent page to the integration:** open the page where databases should live → **⋯** → **Connections** / **Share** → **Invite** → select your integration. `create-databases` verifies it can access this page before creating databases. - -5. **Create databases** — choose one: - - **Recommended — `create-databases`** +2. Set `NOTION_TOKEN` in `.env` (see [Notion setup](#notion-setup)). +3. Share a **parent page** with your integration; set `notion.parent_page_id` in `ntn-roblox.toml`. +4. Configure `[roblox.universes]` **before** `create-db` if using multi-universe (optional). +5. Run `ntn-roblox create-db` — creates four databases and writes IDs to TOML. +6. Set `ROBLOX_API_KEY` in `.env`, `roblox.universe_id` (or `[roblox.universes]`), and `[roblox.asset_creator]` if using Assets. +7. Run `ntn-roblox sync` to create Roblox items; run `ntn-roblox update` after editing Notion rows. - ```bash - # Set parent_page_id in ntn-roblox.toml, or pass --parent-page-id - ntn-roblox create-databases - ``` +See [Notion setup](#notion-setup) and [Roblox setup](#roblox-setup) for details. - Creates **Developer Products**, **Game Passes**, and **Badges** under the parent page with the schema in [Notion database schema](#notion-database-schema), then writes database IDs into `ntn-roblox.toml`. Only `NOTION_TOKEN` is required; database IDs may be empty in the TOML beforehand. - - If database IDs are already configured, `create-databases` exits unless you pass `--force` (creates new databases anyway; IDs in TOML are updated). - - **Manual — create databases yourself** - - Create three Notion databases (one each for Developer Products, Game Passes, and Badges). Use the exact property names and types in [Notion database schema](#notion-database-schema). At minimum, each database needs: - - `Name` (title) — required on every row - - `Sync Status` (select or status — **select recommended**) with options: `Pending`, `Synced`, `Error`, `Skipped` - - Remaining shared columns: `Description`, `Icon`, `Roblox ID`, `Sync Error`, `Last Synced At` - - Type-specific columns: `Price` + `Is For Sale` (Developer Product / Game Pass), or `Is Active` (Badge) - - **Tip:** Duplicate one database twice after setting up the shared columns, then add the type-specific columns to each copy. - - Share **each database** with the integration (same **Connections** / **Invite** flow as the parent page). Copy each 32-character hex ID from the database URL (`.../{database_id}?v=...`) into `ntn-roblox.toml`: - - `notion.dev_product_db_id` - - `notion.game_pass_db_id` - - `notion.badge_db_id` +--- - Hyphens in the URL are optional; paste the ID as shown in Notion. + +
+Notion setup -6. **`Sync Status` options (manual setup):** Add all four options before syncing (as select options or status options). New rows should start as `Pending`. After a successful sync the tool sets `Synced`; failures set `Error` or `Skipped`. (`create-databases` creates these options automatically.) +1. Create an **Internal** integration at [notion.so/my-integrations](https://www.notion.so/my-integrations). +2. Set `NOTION_TOKEN` in `.env`. +3. **Capabilities:** Read content, Update content (sync/update); Insert content (`create-db`). +4. Share the parent page and each database with the integration. +5. Run `ntn-roblox create-db` (recommended) or create databases manually per [Notion database schema](#notion-database-schema).
-Roblox setup — API key, universe, and scopes - -1. Open [Creator Dashboard](https://create.roblox.com/) → your experience → **Creator Hub** → **Open Cloud** → **API Keys** (or the workspace-level API Keys page). - -2. **Create an API key** with access to the **universe** you want to sync. - -3. **Grant scopes** for every resource type you plan to sync (see [Roblox API Key scopes](#roblox-api-key-scopes)): - - Developer Products: `developer-product:read`, `developer-product:write` - - Game Passes: `game-pass:read`, `game-pass:write` - - Badges: `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux`, and `legacy-badge:manage` (icon updates) - -4. **Copy the API key** into `.env` as `ROBLOX_API_KEY`. Store it securely — it is shown only once. +Roblox setup -5. **Universe ID:** from the experience URL or Creator Dashboard, set `roblox.universe_id` in `ntn-roblox.toml`. - -6. **Badge free quota:** new badges are created only when the free quota applies. For group-owned universes, set `roblox.badge_payment_source = "group"` in `ntn-roblox.toml`. +1. Create an API key with scopes for resources you sync (see [Roblox API Key scopes](#roblox-api-key-scopes)). +2. Set `ROBLOX_API_KEY` in `.env`. +3. Set `roblox.universe_id` or `[roblox.universes]` for Dev Products / Game Passes / Badges. +4. Set `[roblox.asset_creator]` for Assets (`is_group` + `id` — user or group upload target). +5. For group-owned badges: `roblox.badge_payment_source = "group"`.
-#### Environment variables (`.env`) - -| Variable | Required for | Purpose | -| --- | --- | --- | -| `NOTION_TOKEN` | `create-databases`, `sync` | Notion integration internal secret | -| `ROBLOX_API_KEY` | `sync` | Roblox Open Cloud API key | - -Do not commit `.env`. Keep database IDs, parent page ID, and universe settings in `ntn-roblox.toml` instead. - #### Configuration (`ntn-roblox.toml`) -**Required for `sync`.** Configuration is read from `ntn-roblox.toml` and `.env`. There are no CLI flags to override TOML values during `sync`. For `create-databases`, `--parent-page-id` can supply `notion.parent_page_id` instead of setting it in the file. Unknown keys are rejected at startup. - -See [ntn-roblox.toml.example](./ntn-roblox.toml.example) for a commented template (same content as `ntn-roblox init`). - -| Key | Required for | Default | Purpose | -| --- | --- | --- | --- | -| `notion.parent_page_id` | `create-databases` | — | Parent page where `create-databases` creates databases (32-char hex ID) | -| `notion.dev_product_db_id` | `sync` | — | Notion database ID for Developer Products (`create-databases` may leave empty beforehand) | -| `notion.game_pass_db_id` | `sync` | — | Notion database ID for Game Passes (`create-databases` may leave empty beforehand) | -| `notion.badge_db_id` | `sync` | — | Notion database ID for Badges (`create-databases` may leave empty beforehand) | -| `roblox.universe_id` | `sync` | — | Target universe ID | -| `roblox.badge_payment_source` | No | `user` | Badge create payment source: `user` or `group` (free quota only) | -| `logging.level` | No | `info` | Log level: `debug`, `info`, `warn`, or `error` | - -Example `ntn-roblox.toml` (after `create-databases`): +| Key | Required for | Purpose | +| --- | --- | --- | +| `notion.parent_page_id` | `create-db` | Parent page for database creation | +| `notion.is_inline` | `create-db` | Inline DBs on parent (default `true`) | +| `notion.dev_product_db_id` | `sync`, `update` | Developer Products database ID | +| `notion.game_pass_db_id` | `sync`, `update` | Game Passes database ID | +| `notion.badge_db_id` | `sync`, `update` | Badges database ID | +| `notion.asset_db_id` | `sync`, `update` | Assets database ID | +| `roblox.universe_id` | `sync`, `update` | Single universe (monetization types) | +| `roblox.universes` | `sync`, `update` | Multi-universe map (alternative to `universe_id`) | +| `roblox.asset_creator` | Assets | `is_group` + `id` for upload ownership | +| `roblox.badge_payment_source` | Badges | `user` or `group` (free quota) | +| `logging.level` | No | `debug` / `info` / `warn` / `error` | + +Example: ```toml [notion] parent_page_id = "your-parent-page-id" -dev_product_db_id = "your-dev-product-database-id" -game_pass_db_id = "your-game-pass-database-id" -badge_db_id = "your-badge-database-id" +dev_product_db_id = "..." +game_pass_db_id = "..." +badge_db_id = "..." +asset_db_id = "..." [roblox] universe_id = 1234567890 -# badge_payment_source = "user" + +[roblox.asset_creator] +is_group = false +id = 123456789 [logging] # level = "info" ``` -### First sync - -```bash -ntn-roblox sync # full sync (all resource types) -ntn-roblox sync --dry-run # log planned actions only -ntn-roblox sync --type=badge # one resource type -``` - --- ## Notion database schema -Create three databases using the property names and types below. These names are **case-sensitive** and must match exactly. +Property names are **case-sensitive**. Extra columns are ignored — you may add your own properties freely. -### Shared properties +### Shared (Developer Products, Game Passes, Badges) -| Property | Notion type | Description | +| Property | Type | Description | | --- | --- | --- | -| `Name` | title | Required. Maps to Roblox `name`. | -| `Description` | rich_text | Roblox `description`. | -| `Icon` | files | Only the first file is synced. Skips icon update when empty. | -| `Roblox ID` | number | Match key. Written back after a successful Create. | -| `Sync Status` | select **or** status | `Pending` / `Synced` / `Error` / `Skipped`. Both **select** and **status** are supported for read and writeback. **select** is recommended. | -| `Sync Error` | rich_text | Failure or skip reason. Cleared on success. | -| `Last Synced At` | date | Timestamp on successful sync. | - -### DeveloperProduct and GamePass only +| `Name` | title | Required | +| `Description` | rich_text | Roblox description | +| `Icon` | files | First file only (Dev Product / Game Pass / Badge) | +| `Roblox ID` | number | Single-universe mode | +| `Roblox ID (Key)` | number | Multi-universe: one column per TOML key | +| `Sync Status` | select | `Pending` / `Synced` / `Error` / `Skipped` | +| `Sync Error` | rich_text | Cleared on success | +| `Last Synced At` | date | Success timestamp | -| Property | Notion type | Description | -| --- | --- | --- | -| `Price` | number | Robux price. Required on Create. | -| `Is For Sale` | checkbox | Maps to `isForSale`. Defaults to `true` when unset. | +Create vs update is decided by whether the Roblox ID column is set, **not** by Sync Status. -### Badge only +### Assets database -| Property | Notion type | Description | +| Property | Type | Description | | --- | --- | --- | -| `Is Active` | checkbox | Create: `isActive` / Update: `enabled`. Defaults to `true` when unset. | +| `Name` | title | Roblox `displayName` | +| `File` | files | Upload file (first attachment). **Create only** | +| `Asset Type` | select | `Animation`, `Audio`, `Decal`, `Image`, `Model` | +| `Roblox ID` | number | Always single column (even in multi-universe) | +| `Description` | rich_text | Optional; empty sends `""` | +| `Sync Status` / `Sync Error` / `Last Synced At` | — | Same as above | + +**Not supported:** Video (paid), Mesh (Roblox-delivery only). --- ## Roblox API Key scopes -Grant the following scopes on your API key. - -| Resource | Required scopes | +| Resource | Scopes | | --- | --- | | Developer Product | `developer-product:read`, `developer-product:write` | | Game Pass | `game-pass:read`, `game-pass:write` | -| Badge (create & update) | `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux` | -| Badge (icon update) | `legacy-badge:manage` | - -Badge listing and free-quota checks use the public `badges.roblox.com` API and do not require an API key. - ---- - -## Use in Roblox projects - -Typical layout when using Rokit: - -``` -my-game/ - .env # secrets (gitignored) - ntn-roblox.toml # parent page ID, DB IDs, universe_id (safe to commit) - rokit.toml # ntn-roblox = "Zac134/NotionToRoblox@0.1.1" - default.project.json -``` - -Run `ntn-roblox init`, `ntn-roblox create-databases`, or `ntn-roblox sync` from the project root (where `.env` and `ntn-roblox.toml` live). Add sync to CI or a pre-commit hook to keep Notion authoritative. +| Badge | `legacy-universe.badge:write`, `legacy-universe.badge:manage-and-spend-robux`, `legacy-badge:manage` | +| Assets | `asset:read`, `asset:write` | --- ## CLI -`sync` is the default command when omitted. +Global options (all commands): `--help`, `-h`, `--version`, `-V` -### `init` +### `sync` (default) -Create `.env` and `ntn-roblox.toml` in the current directory. No Notion or Roblox credentials required. +Create Roblox items for rows **without** a Roblox ID. ```bash -ntn-roblox init [--force] +ntn-roblox sync [--dry-run] [--report-only] [--type=...] [--target=Key] ``` | Option | Description | | --- | --- | -| `--force` | Overwrite existing `.env` / `ntn-roblox.toml` | -| `--help`, `-h` | Show help | +| `--dry-run` | Log planned creates only | +| `--report-only` | Orphan report only (monetization types) | +| `--type=` | `developer-product`, `game-pass`, `badge`, or `asset` | +| `--target=` | One universe (requires `[roblox.universes]`) | -Existing files are skipped unless `--force` is set. After filling in secrets and `parent_page_id`, run `create-databases`. +### `update` -### `create-databases` - -Create the three Notion databases under a parent page and write their IDs into `ntn-roblox.toml`. Requires `NOTION_TOKEN` only. +Push current Notion fields to **existing** Roblox items (ID set). ```bash -ntn-roblox create-databases [--parent-page-id=] [--force] +ntn-roblox update [--dry-run] [--type=...] [--target=Key] ``` -| Option | Description | -| --- | --- | -| `--parent-page-id=` | Parent page ID (overrides `notion.parent_page_id` in TOML) | -| `--force` | Create new databases even when database IDs are already configured | -| `--help`, `-h` | Show help | +Assets: metadata only (`Name`, `Description`). `File` is ignored on update. -Share the parent page with your integration before running `create-databases`. Database IDs may be empty in `ntn-roblox.toml` beforehand; they are required before `sync`. +### `create-db` -### `sync` +Creates four Notion databases. Alias: `create-databases`. ```bash -ntn-roblox sync [--dry-run] [--report-only] [--force] [--type=developer-product|game-pass|badge] +ntn-roblox create-db [--parent-page-id=] [--force] ``` -| Option | Description | -| --- | --- | -| `--dry-run` | Log planned mutations without writing to Roblox or Notion | -| `--report-only` | List Roblox orphans only; skip create/update | -| `--force` | Include rows with `Sync Status = Synced` in create/update (default: skipped) | -| `--type=` | Limit to one resource type (`developer-product`, `game-pass`, or `badge`) | -| `--help`, `-h` | Show help | - -Exit code (`sync`): `1` if any row ends in `Error` or `Skipped`; otherwise `0`. - --- -## Sync overview - -1. Fetch current Roblox state via List APIs. -2. Report Roblox items not present in Notion to the console (runs in all modes including `--dry-run` and `--report-only`; no Notion writes). Orphan reporting is informational only and does not affect the exit code. -3. Create or update Notion rows whose `Sync Status` is not `Synced` (or all rows when `--force` is set), then write results back to Notion. - -Rows with `Sync Status = Synced` are skipped unless `--force` is set. With `--force`, Synced rows are create/update targets: rows without `Roblox ID` are created; rows with `Roblox ID` are updated. Rows with `Roblox ID` set and status `Pending`, `Error`, or `Skipped` are always updated on Roblox. +## Multi-universe -If Roblox Create succeeds but Notion writeback fails completely, the CLI logs a `CRITICAL` message with the new `robloxId`. Manually set that `Roblox ID` on the Notion page and set `Sync Status` to `Synced` or `Error`. +```toml +[roblox.universes] +Prod = 111111111 +Dev = 222222222 +``` -**Badge free quota:** New badges are created only when the free quota applies (`expectedCost=0`). Paid creation (100 Robux each) is not supported. When quota is 0, creation is skipped with `Skipped` status and retried automatically on the next sync after the daily GMT reset. Set `badge_payment_source = "group"` in `ntn-roblox.toml` for group-owned universes. +- Dev Product / Game Pass / Badge: `Roblox ID (Prod)` etc.; `--target=Prod` limits scope. +- **Assets:** single `Roblox ID` column; creator-scoped, not per-universe. --- -## Errors - -The CLI exits with code `1` when configuration is invalid, mapping fails, or any row ends in `Error` or `Skipped`. Common cases: - -- **Missing `ntn-roblox.toml`** — run `ntn-roblox init`, or copy from [ntn-roblox.toml.example](./ntn-roblox.toml.example) -- **Missing or invalid secrets** — set `NOTION_TOKEN` in `.env` for `create-databases`; both `NOTION_TOKEN` and `ROBLOX_API_KEY` for `sync` -- **Missing `notion.parent_page_id` on create-databases** — set it in `ntn-roblox.toml` or pass `--parent-page-id` -- **Parent page not shared with integration** — share the page via **Connections** / **Invite** (see [Notion setup](#notion-setup)) -- **Database IDs already configured on create-databases** — remove or update stale IDs, or pass `--force` -- **Unknown TOML keys** — the config schema is strict; remove extra keys -- **Invalid `Sync Status` value** — must be one of `Pending`, `Synced`, `Error`, `Skipped` -- **Missing `Price` on Create** — Developer Product and Game Pass rows require a price -- **Badge free quota exhausted** — row is marked `Skipped`; retries after the daily GMT reset -- **Notion permission errors** — for `create-databases`, ensure **Insert content** and parent-page access; for `sync`, ensure **Read content** and **Update content**, and all three databases are shared (see [Notion setup](#notion-setup)) -- **Roblox API errors** — verify API key scopes (see [Roblox API Key scopes](#roblox-api-key-scopes)) - ---- +## Assets -
-Contributing & development (Node.js) +Upload free asset types via Open Cloud Assets API. Configure `[roblox.asset_creator]`: -### Requirements +```toml +[roblox.asset_creator] +is_group = true # true = groupId, false = userId +id = 987654321 +``` -- Node.js 22+ -- npm 10.9.2+ +| Asset Type | Upload | Update content | +| --- | --- | --- | +| Decal, Image, Audio, Animation, Model | ✅ | Metadata only (`update`) | -### Setup +Video (2000 Robux) and Mesh are excluded. -```bash -npm install -ntn-roblox init # or: npm run init -# Edit .env and ntn-roblox.toml -``` +--- -### Development commands +## Migrating from 0.1.x -```bash -npm run build # tsc -npm run check # type check -npm run start -- init --help # init command -npm run start -- create-databases --help # create-databases command -npm run start -- sync --help # run compiled CLI -npm run sync # Full sync (no build required) -npm run sync -- --dry-run # No mutations (planned actions only) -npm run sync -- --report-only # Orphan report only -npm run sync -- --type=developer-product|game-pass|badge -``` +- `sync --force` removed — use `update` to push changes to existing items. +- `sync` creates only; `update` updates only. +- Add `asset_db_id` and `asset_creator` for Assets. +- Multi-universe: switch from `universe_id` to `[roblox.universes]` and rename ID columns. -### Project layout +--- -``` -ntn-roblox.toml.example # Non-secret config template (same as init output) -src/ - cli.ts # CLI entry - env.ts # .env loader (process.cwd()) - toml.ts # ntn-roblox.toml loader (process.cwd()) - tomlWrite.ts # create-databases TOML updates - config.ts # .env + TOML validation (zod) - init/ # Config scaffolding (init command) - createDatabases/ # Notion database bootstrap (create-databases command) - sync/ - engine.ts # Sync orchestration - candidates.ts # Row action selection - orphanReport.ts # Orphan reporting - notion/ - mapRow.ts # Notion row mapping - writeback.ts # Notion writeback - roblox/ # Open Cloud & badge API clients - util/ -``` +## Errors -### Building binaries (maintainers) +Exit code `1` on config errors, mapping failures, or any row ending in `Error` / `Skipped`. -Release artifacts are standalone Bun-compiled binaries packaged as Rokit-compatible zip files. +--- -**Prerequisites:** [Bun](https://bun.sh) on `PATH`, and `zip` or Python 3 +
+Contributing & development ```bash -npm run compile -- 0.1.1 bun-darwin-arm64 ./release -``` - -| `bun-target` | Zip suffix | -| --- | --- | -| `bun-linux-x64` | `linux-x86_64` | -| `bun-linux-arm64` | `linux-aarch64` | -| `bun-darwin-x64` | `macos-x86_64` | -| `bun-darwin-arm64` | `macos-aarch64` | -| `bun-windows-x64` | `windows-x86_64` | -| `bun-windows-arm64` | `windows-aarch64` | - -Output: `NotionToRoblox---.zip` containing a single `NotionToRoblox` (or `NotionToRoblox.exe` on Windows) binary. Rokit installs that binary and links it on `PATH` as `ntn-roblox` when your `rokit.toml` uses the `ntn-roblox` alias. - -#### Release checklist - -1. Confirm checks pass: `npm run check` -2. Align version strings in `package.json`, README, and the commented consumer example in this repo's `rokit.toml` (the line stays commented here; consumer projects uncomment it in their own `rokit.toml`) -3. Tag and push: - ```bash - git tag v0.1.1 - git push origin v0.1.1 +npm install +npm run check +npm test +npm run sync -- --dry-run +npm run update -- --dry-run ``` -4. GitHub Actions builds all six targets and attaches zip assets to the Release -5. Verify from a clean Roblox project: - ```bash - rokit add Zac134/NotionToRoblox@0.1.1 ntn-roblox - rokit install - ntn-roblox sync --help - ``` - -#### Pre-publication checklist (maintainers) - -Before making the repository public or cutting the first GitHub Release: -- [ ] Set the GitHub repository visibility to **Public** -- [ ] Confirm no secrets in git history or tracked files (`.env`, API keys, real tokens in examples) -- [ ] Enable **Dependabot** alerts and version updates (see [`.github/dependabot.yml`](./.github/dependabot.yml)) -- [ ] Enable **Private vulnerability reporting** (Security → Advisories) — recommended - -See [SECURITY.md](./SECURITY.md) for vulnerability reporting. +Release: `npm run compile -- 0.2.0 bun-darwin-arm64 ./release`
--- -## Security - -Report vulnerabilities privately via [GitHub Security Advisories](https://github.com/Zac134/NotionToRoblox/security/advisories/new) or a high-level GitHub Issue. **Do not paste secrets** (`NOTION_TOKEN`, `ROBLOX_API_KEY`, `.env` contents) in public reports. - -Details: [SECURITY.md](./SECURITY.md) - ---- - ## License -MIT License. See [LICENSE](./LICENSE) for details. +MIT — see [LICENSE](./LICENSE). diff --git a/ntn-roblox.toml.example b/ntn-roblox.toml.example index 8eaef5d..0255345 100644 --- a/ntn-roblox.toml.example +++ b/ntn-roblox.toml.example @@ -1,17 +1,25 @@ # Generated by `ntn-roblox init`. Safe to commit — secrets belong in .env only. [notion] -# Parent page where `ntn-roblox create-databases` creates the three databases +# Parent page where `ntn-roblox create-db` creates the databases # (32-char hex ID from the page URL). Share this page with your integration first. parent_page_id = "" -# Filled by `ntn-roblox create-databases`, or set manually if you create databases yourself. +# Filled by `ntn-roblox create-db`, or set manually if you create databases yourself. dev_product_db_id = "" game_pass_db_id = "" badge_db_id = "" +asset_db_id = "" +# is_inline = true [roblox] +# Use universe_id for a single universe, OR universes for multi-universe sync (not both). universe_id = 1234567890 +# universes = { main = 1234567890, staging = 9876543210 } # badge_payment_source = "user" +# Required when asset_db_id is configured (for asset upload ownership): +# [roblox.asset_creator] +# is_group = false +# id = 12345678 [logging] # level = "info" diff --git a/package-lock.json b/package-lock.json index d4c521a..d193901 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "notion-to-roblox", - "version": "0.1.1", + "version": "0.2.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "notion-to-roblox", - "version": "0.1.1", + "version": "0.2.0", "license": "MIT", "dependencies": { "@notionhq/client": "^2.2.15", diff --git a/package.json b/package.json index 40d0303..5e8d8f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "notion-to-roblox", - "version": "0.1.1", + "version": "0.2.0", "private": true, "description": "CLI to sync Notion database records to Roblox Developer Products, Game Passes, and Badges via Open Cloud API", "license": "MIT", @@ -24,8 +24,10 @@ "check": "tsc --noEmit", "test": "node --experimental-test-module-mocks --import tsx --import ./test/setup.ts --test test/**/*.test.ts", "init": "tsx src/cli.ts init", + "create-db": "tsx src/cli.ts create-databases", "create-databases": "tsx src/cli.ts create-databases", - "sync": "tsx src/cli.ts", + "sync": "tsx src/cli.ts sync", + "update": "tsx src/cli.ts update", "start": "node dist/cli.js", "compile": "bash scripts/package-release.sh" }, diff --git a/rokit.toml b/rokit.toml index 5c73cf8..14d5e0d 100644 --- a/rokit.toml +++ b/rokit.toml @@ -5,4 +5,4 @@ [tools] # After publishing a GitHub Release, consumer projects can add: -# ntn-roblox = "Zac134/NotionToRoblox@0.1.1" +# ntn-roblox = "Zac134/NotionToRoblox@0.2.0" diff --git a/scripts/package-release.sh b/scripts/package-release.sh index 04f2f34..17072f3 100755 --- a/scripts/package-release.sh +++ b/scripts/package-release.sh @@ -66,7 +66,11 @@ trap 'rm -rf "$WORK_DIR"' EXIT BINARY_PATH="$WORK_DIR/$BIN_NAME" echo "Compiling src/cli.ts for $BUN_TARGET ..." -bun build src/cli.ts --compile --target="$BUN_TARGET" --outfile="$BINARY_PATH" +bun build src/cli.ts \ + --compile \ + --target="$BUN_TARGET" \ + --define "NTN_ROBLOX_VERSION=\"$VERSION\"" \ + --outfile="$BINARY_PATH" if [[ "$BIN_NAME" != *.exe ]]; then chmod +x "$BINARY_PATH" diff --git a/src/assetTypes.ts b/src/assetTypes.ts new file mode 100644 index 0000000..883d4e4 --- /dev/null +++ b/src/assetTypes.ts @@ -0,0 +1,13 @@ +export const SUPPORTED_ASSET_TYPES = [ + "Animation", + "Audio", + "Decal", + "Image", + "Model", +] as const; + +export type SupportedAssetType = (typeof SUPPORTED_ASSET_TYPES)[number]; + +export function isSupportedAssetType(value: string): value is SupportedAssetType { + return (SUPPORTED_ASSET_TYPES as readonly string[]).includes(value); +} diff --git a/src/cli.ts b/src/cli.ts index 1be1901..51b8ce8 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -1,5 +1,6 @@ #!/usr/bin/env node +import { pathToFileURL } from "node:url"; import { loadCreateDatabasesConfig, loadSyncConfig, @@ -10,29 +11,34 @@ import { loadEnvFile } from "./env.js"; import { runInit } from "./init/runInit.js"; import type { ResourceType } from "./types.js"; import { setLogLevel } from "./util/logger.js"; +import { VERSION } from "./version.js"; -type Command = "sync" | "init" | "create-databases"; +type SyncCommand = "sync" | "update"; +type Command = SyncCommand | "init" | "create-databases" | "create-db"; interface SyncParsedArgs { - command: "sync"; + command: SyncCommand; dryRun: boolean; reportOnly: boolean; - force: boolean; typeFilter?: ResourceType; + targetFilter?: string; help: boolean; + version: boolean; } interface InitParsedArgs { command: "init"; force: boolean; help: boolean; + version: boolean; } interface CreateDatabasesParsedArgs { - command: "create-databases"; + command: "create-databases" | "create-db"; parentPageId?: string; force: boolean; help: boolean; + version: boolean; } type ParsedArgs = @@ -44,41 +50,56 @@ const RESOURCE_TYPES: ResourceType[] = [ "developer-product", "game-pass", "badge", + "asset", ]; -const COMMANDS: Command[] = ["sync", "init", "create-databases"]; +const COMMANDS: Command[] = [ + "sync", + "update", + "init", + "create-databases", + "create-db", +]; function printUsage(): void { - console.error(`Usage: + console.log(`Usage: ntn-roblox init [options] + ntn-roblox create-db [options] ntn-roblox create-databases [options] ntn-roblox sync [options] + ntn-roblox update [options] Development: npm run init -- [options] - npm run create-databases -- [options] + npm run create-db -- [options] npm run sync -- [options] + npm run update -- [options] Commands: init Create .env and ntn-roblox.toml in the current directory + create-db Alias for create-databases create-databases Create Notion databases and write IDs to ntn-roblox.toml - sync Run full synchronization (default) + sync Create Roblox items for rows without Roblox ID + update Update Roblox items for rows with Roblox ID Init options: --force Overwrite existing .env / ntn-roblox.toml --help, -h Show this help message + --version, -V Show version Create-databases options: --parent-page-id= Notion parent page ID (overrides ntn-roblox.toml) --force Create new databases even if IDs are already configured --help, -h Show this help message + --version, -V Show version -Sync options: +Sync / update options: --dry-run Log planned mutations without writing to Roblox or Notion --report-only List Roblox orphans only; skip create/update - --force Re-sync rows with Sync Status = Synced --type= Limit to one resource type (${RESOURCE_TYPES.join(" | ")}) + --target= Limit to one universe key (multi-universe configs only) --help, -h Show this help message + --version, -V Show version `); } @@ -97,10 +118,6 @@ function parseSyncArg(arg: string, parsed: SyncParsedArgs): void { parsed.reportOnly = true; return; } - if (arg === "--force") { - parsed.force = true; - return; - } if (arg.startsWith("--type=")) { const value = arg.slice("--type=".length) as ResourceType; if (!RESOURCE_TYPES.includes(value)) { @@ -111,6 +128,14 @@ function parseSyncArg(arg: string, parsed: SyncParsedArgs): void { parsed.typeFilter = value; return; } + if (arg.startsWith("--target=")) { + const value = arg.slice("--target=".length).trim(); + if (!value) { + throw new Error("Invalid --target value: value must not be empty"); + } + parsed.targetFilter = value; + return; + } throw new Error(`Unknown argument: ${arg}`); } @@ -119,6 +144,7 @@ function parseInitArg(arg: string, parsed: InitParsedArgs): void { arg === "--dry-run" || arg === "--report-only" || arg.startsWith("--type=") || + arg.startsWith("--target=") || arg.startsWith("--parent-page-id=") || arg === "--write-toml" ) { @@ -139,6 +165,7 @@ function parseCreateDatabasesArg( arg === "--dry-run" || arg === "--report-only" || arg.startsWith("--type=") || + arg.startsWith("--target=") || arg === "--write-toml" ) { throw new Error(`Unknown argument: ${arg}`); @@ -164,9 +191,17 @@ function isCommand(value: string): value is Command { return (COMMANDS as string[]).includes(value); } +function normalizeCommand(command: Command): ParsedArgs["command"] { + if (command === "create-db") { + return "create-databases"; + } + return command; +} + function parseArgs(argv: string[]): ParsedArgs { let command: Command | undefined; let help = false; + let version = false; for (const arg of argv) { if (isCommand(arg)) { @@ -180,23 +215,25 @@ function parseArgs(argv: string[]): ParsedArgs { const resolvedCommand = command ?? "sync"; const syncParsed: SyncParsedArgs = { - command: "sync", + command: resolvedCommand === "update" ? "update" : "sync", dryRun: false, reportOnly: false, - force: false, help: false, + version: false, }; const initParsed: InitParsedArgs = { command: "init", force: false, help: false, + version: false, }; const createDatabasesParsed: CreateDatabasesParsedArgs = { command: "create-databases", force: false, help: false, + version: false, }; for (const arg of argv) { @@ -204,6 +241,10 @@ function parseArgs(argv: string[]): ParsedArgs { help = true; continue; } + if (arg === "--version" || arg === "-V") { + version = true; + continue; + } if (isCommand(arg)) { continue; } @@ -211,7 +252,7 @@ function parseArgs(argv: string[]): ParsedArgs { throw new Error(`Unknown argument: ${arg}`); } - if (resolvedCommand === "sync") { + if (resolvedCommand === "sync" || resolvedCommand === "update") { parseSyncArg(arg, syncParsed); } else if (resolvedCommand === "init") { parseInitArg(arg, initParsed); @@ -222,15 +263,18 @@ function parseArgs(argv: string[]): ParsedArgs { if (resolvedCommand === "init") { initParsed.help = help; + initParsed.version = version; return initParsed; } - if (resolvedCommand === "create-databases") { + if (resolvedCommand === "create-databases" || resolvedCommand === "create-db") { createDatabasesParsed.help = help; + createDatabasesParsed.version = version; return createDatabasesParsed; } syncParsed.help = help; + syncParsed.version = version; return syncParsed; } @@ -239,6 +283,11 @@ async function main(): Promise { const args = parseArgs(process.argv.slice(2)); + if (args.version) { + console.log(VERSION); + return; + } + if (args.help) { printUsage(); return; @@ -258,15 +307,20 @@ async function main(): Promise { return; } + if (args.command !== "sync" && args.command !== "update") { + return; + } + setConfig(loadSyncConfig()); const { runSync, shouldExitWithError } = await import("./sync/engine.js"); const result = await runSync({ + mode: args.command, dryRun: args.dryRun, reportOnly: args.reportOnly, - force: args.force, typeFilter: args.typeFilter, + targetFilter: args.targetFilter, }); if (shouldExitWithError(result)) { @@ -274,8 +328,16 @@ async function main(): Promise { } } -main().catch(async (error) => { - const { logger } = await import("./util/logger.js"); - logger.error(error instanceof Error ? error.message : String(error)); - process.exitCode = 1; -}); +const isMainModule = + process.argv[1] !== undefined && + import.meta.url === pathToFileURL(process.argv[1]).href; + +if (isMainModule) { + main().catch(async (error) => { + const { logger } = await import("./util/logger.js"); + logger.error(error instanceof Error ? error.message : String(error)); + process.exitCode = 1; + }); +} + +export { parseArgs, printUsage, VERSION }; diff --git a/src/config.ts b/src/config.ts index 27018fc..0779633 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,4 +1,5 @@ import { z } from "zod"; +import { assertValidUniverseKey } from "./notion/propertyNames.js"; import { loadTomlFile } from "./toml.js"; import { setLogLevel } from "./util/logger.js"; @@ -11,6 +12,62 @@ const createDatabasesSecretsSchema = z.object({ NOTION_TOKEN: z.string().min(1, "NOTION_TOKEN is required"), }); +const assetCreatorSchema = z + .object({ + is_group: z.boolean(), + id: z.number().int().positive(), + }) + .strict(); + +const robloxTomlBaseSchema = z + .object({ + universe_id: z.number().int().positive().optional(), + universes: z.record(z.string(), z.number().int().positive()).optional(), + badge_payment_source: z.enum(["user", "group"]).default("user"), + asset_creator: assetCreatorSchema.optional(), + }) + .strict(); + +function validateUniverseConfig( + roblox: z.infer, + ctx: z.RefinementCtx, +): void { + const hasUniverseId = roblox.universe_id !== undefined; + const hasUniverses = + roblox.universes !== undefined && Object.keys(roblox.universes).length > 0; + + if (hasUniverseId && hasUniverses) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "roblox.universe_id and roblox.universes are mutually exclusive", + path: ["universe_id"], + }); + } + + if (!hasUniverseId && !hasUniverses) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Either roblox.universe_id or roblox.universes is required", + path: ["universe_id"], + }); + } + + if (hasUniverses) { + for (const key of Object.keys(roblox.universes!)) { + try { + assertValidUniverseKey(key); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + error instanceof Error ? error.message : "Invalid universe key", + path: ["universes", key], + }); + } + } + } +} + const syncTomlSchema = z .object({ notion: z @@ -18,15 +75,12 @@ const syncTomlSchema = z dev_product_db_id: z.string().min(1), game_pass_db_id: z.string().min(1), badge_db_id: z.string().min(1), + asset_db_id: z.string().min(1), parent_page_id: z.string().optional(), + is_inline: z.boolean().default(true), }) .strict(), - roblox: z - .object({ - universe_id: z.number().int().positive(), - badge_payment_source: z.enum(["user", "group"]).default("user"), - }) - .strict(), + roblox: robloxTomlBaseSchema.superRefine(validateUniverseConfig), logging: z .object({ level: z.enum(["debug", "info", "warn", "error"]).default("info"), @@ -43,16 +97,46 @@ const createDatabasesTomlSchema = z dev_product_db_id: z.string().optional(), game_pass_db_id: z.string().optional(), badge_db_id: z.string().optional(), + asset_db_id: z.string().optional(), parent_page_id: z.string().optional(), + is_inline: z.boolean().default(true), }) .strict() .default({}), - roblox: z - .object({ - universe_id: z.number().int().positive().optional(), - badge_payment_source: z.enum(["user", "group"]).default("user"), + roblox: robloxTomlBaseSchema + .partial({ badge_payment_source: true }) + .superRefine((roblox, ctx) => { + const hasUniverseId = roblox.universe_id !== undefined; + const hasUniverses = + roblox.universes !== undefined && + Object.keys(roblox.universes).length > 0; + + if (hasUniverseId && hasUniverses) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + "roblox.universe_id and roblox.universes are mutually exclusive", + path: ["universe_id"], + }); + } + + if (hasUniverses) { + for (const key of Object.keys(roblox.universes!)) { + try { + assertValidUniverseKey(key); + } catch (error) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: + error instanceof Error + ? error.message + : "Invalid universe key", + path: ["universes", key], + }); + } + } + } }) - .strict() .optional(), logging: z .object({ @@ -63,15 +147,26 @@ const createDatabasesTomlSchema = z }) .strict(); +export type RobloxAssetCreator = z.infer; + +export type SyncTarget = { + key: string | null; + universeId: number; +}; + export type Config = { NOTION_TOKEN: string; ROBLOX_API_KEY: string; NOTION_DEVPRODUCT_DB_ID: string; NOTION_GAMEPASS_DB_ID: string; NOTION_BADGE_DB_ID: string; + NOTION_ASSET_DB_ID: string; NOTION_PARENT_PAGE_ID?: string; + NOTION_IS_INLINE: boolean; ROBLOX_UNIVERSE_ID: number; + ROBLOX_UNIVERSES: Record; ROBLOX_BADGE_PAYMENT_SOURCE: "user" | "group"; + ROBLOX_ASSET_CREATOR?: RobloxAssetCreator; LOG_LEVEL: "debug" | "info" | "warn" | "error"; }; @@ -94,7 +189,51 @@ function normalizeOptionalDbId(value: string | undefined): string { return value?.trim() ?? ""; } +function buildRobloxUniverses(roblox: { + universe_id?: number; + universes?: Record; +}): Record { + if (roblox.universes !== undefined) { + return { ...roblox.universes }; + } + return {}; +} + +function resolveInitialUniverseId(roblox: { + universe_id?: number; + universes?: Record; +}): number { + if (roblox.universe_id !== undefined) { + return roblox.universe_id; + } + if ( + roblox.universes !== undefined && + Object.keys(roblox.universes).length > 0 + ) { + const keys = Object.keys(roblox.universes).sort(); + return roblox.universes[keys[0]!]!; + } + return 0; +} + +function validateAssetCreatorWhenAssetDbConfigured( + assetDbId: string, + assetCreator: RobloxAssetCreator | undefined, + errors: string[], +): void { + if (!isNotionDatabaseIdConfigured(assetDbId)) { + return; + } + if (!assetCreator) { + errors.push( + "roblox.asset_creator: required when notion.asset_db_id is configured", + ); + } +} + let currentConfig: Config | null = null; +let multiUniverseMode = false; +let activeUniverseKey: string | null = null; export function getConfig(): Config { if (!currentConfig) { @@ -110,6 +249,67 @@ export function setConfig(config: Config): void { setLogLevel(config.LOG_LEVEL); } +export function isMultiUniverseMode(): boolean { + return multiUniverseMode; +} + +export function getUniverseKeys(): string[] { + if (!multiUniverseMode) { + return []; + } + return Object.keys(getConfig().ROBLOX_UNIVERSES).sort(); +} + +export function setActiveUniverse(key: string | null): void { + if (!currentConfig) { + throw new Error( + "Configuration has not been loaded. Call setConfig(loadSyncConfig()) first.", + ); + } + + if (!multiUniverseMode) { + if (key !== null) { + throw new Error("Cannot set universe key in single-universe mode"); + } + activeUniverseKey = null; + return; + } + + if (key === null) { + throw new Error("Universe key is required in multi-universe mode"); + } + + assertValidUniverseKey(key); + if (!(key in currentConfig.ROBLOX_UNIVERSES)) { + throw new Error(`Unknown universe key: ${key}`); + } + + activeUniverseKey = key; + currentConfig.ROBLOX_UNIVERSE_ID = currentConfig.ROBLOX_UNIVERSES[key]!; +} + +export function resolveSyncTargets(cliTarget?: string): SyncTarget[] { + const config = getConfig(); + + if (!multiUniverseMode) { + return [{ key: null, universeId: config.ROBLOX_UNIVERSE_ID }]; + } + + if (cliTarget !== undefined) { + assertValidUniverseKey(cliTarget); + const universeId = config.ROBLOX_UNIVERSES[cliTarget]; + if (universeId === undefined) { + throw new Error(`Unknown universe key: ${cliTarget}`); + } + return [{ key: cliTarget, universeId }]; + } + + return getUniverseKeys().map((key) => ({ + key, + universeId: config.ROBLOX_UNIVERSES[key]!, + })); +} + export function loadSyncConfig(options?: { env?: NodeJS.ProcessEnv; tomlPath?: string; @@ -138,15 +338,37 @@ export function loadSyncConfig(options?: { const secrets = secretsResult.data!; const toml = tomlResult.data!; + validateAssetCreatorWhenAssetDbConfigured( + toml.notion.asset_db_id, + toml.roblox.asset_creator, + errors, + ); + + if (errors.length > 0) { + throw new Error(`Invalid configuration:\n${errors.join("\n")}`); + } + + multiUniverseMode = toml.roblox.universes !== undefined; + activeUniverseKey = multiUniverseMode + ? Object.keys(toml.roblox.universes!).sort()[0]! + : null; + + const robloxUniverses = buildRobloxUniverses(toml.roblox); + const robloxUniverseId = resolveInitialUniverseId(toml.roblox); + const config: Config = { NOTION_TOKEN: secrets.NOTION_TOKEN, ROBLOX_API_KEY: secrets.ROBLOX_API_KEY, NOTION_DEVPRODUCT_DB_ID: toml.notion.dev_product_db_id, NOTION_GAMEPASS_DB_ID: toml.notion.game_pass_db_id, NOTION_BADGE_DB_ID: toml.notion.badge_db_id, + NOTION_ASSET_DB_ID: toml.notion.asset_db_id, NOTION_PARENT_PAGE_ID: trimOptional(toml.notion.parent_page_id), - ROBLOX_UNIVERSE_ID: toml.roblox.universe_id, + NOTION_IS_INLINE: toml.notion.is_inline, + ROBLOX_UNIVERSE_ID: robloxUniverseId, + ROBLOX_UNIVERSES: robloxUniverses, ROBLOX_BADGE_PAYMENT_SOURCE: toml.roblox.badge_payment_source, + ROBLOX_ASSET_CREATOR: toml.roblox.asset_creator, LOG_LEVEL: toml.logging.level, }; @@ -186,10 +408,17 @@ export function loadCreateDatabasesConfig(options?: { trimOptional(toml.notion.parent_page_id); if (!parentPageId) { throw new Error( - "Invalid configuration:\nnotion.parent_page_id: parent page ID is required for create-databases (set in ntn-roblox.toml or pass --parent-page-id)", + "Invalid configuration:\nnotion.parent_page_id: parent page ID is required for create-db (set in ntn-roblox.toml or pass --parent-page-id)", ); } + multiUniverseMode = toml.roblox?.universes !== undefined; + activeUniverseKey = null; + + const robloxUniverses = toml.roblox ? buildRobloxUniverses(toml.roblox) : {}; + const robloxUniverseId = + toml.roblox !== undefined ? resolveInitialUniverseId(toml.roblox) : 0; + const config: Config = { NOTION_TOKEN: secrets.NOTION_TOKEN, ROBLOX_API_KEY: "", @@ -198,9 +427,13 @@ export function loadCreateDatabasesConfig(options?: { ), NOTION_GAMEPASS_DB_ID: normalizeOptionalDbId(toml.notion.game_pass_db_id), NOTION_BADGE_DB_ID: normalizeOptionalDbId(toml.notion.badge_db_id), + NOTION_ASSET_DB_ID: normalizeOptionalDbId(toml.notion.asset_db_id), NOTION_PARENT_PAGE_ID: parentPageId, - ROBLOX_UNIVERSE_ID: toml.roblox?.universe_id ?? 0, + NOTION_IS_INLINE: toml.notion.is_inline, + ROBLOX_UNIVERSE_ID: robloxUniverseId, + ROBLOX_UNIVERSES: robloxUniverses, ROBLOX_BADGE_PAYMENT_SOURCE: toml.roblox?.badge_payment_source ?? "user", + ROBLOX_ASSET_CREATOR: toml.roblox?.asset_creator, LOG_LEVEL: toml.logging.level, }; diff --git a/src/createDatabases/createDatabases.ts b/src/createDatabases/createDatabases.ts index cea9c71..4b6735f 100644 --- a/src/createDatabases/createDatabases.ts +++ b/src/createDatabases/createDatabases.ts @@ -5,6 +5,7 @@ import type { } from "@notionhq/client/build/src/api-endpoints.js"; import { notionRateLimiter } from "../util/rateLimit.js"; import { + assetDatabaseProperties, badgeDatabaseProperties, DATABASE_TITLES, developerProductDatabaseProperties, @@ -15,6 +16,7 @@ export interface CreatedDatabaseIds { devProductDbId: string; gamePassDbId: string; badgeDbId: string; + assetDbId: string; } export type PartialCreatedDatabaseIds = Partial; @@ -50,6 +52,11 @@ const DATABASE_SPECS = [ properties: badgeDatabaseProperties, idKey: "badgeDbId" as const, }, + { + title: DATABASE_TITLES.asset, + properties: assetDatabaseProperties, + idKey: "assetDbId" as const, + }, ] as const; export async function createAllDatabases( diff --git a/src/createDatabases/databaseSchemas.ts b/src/createDatabases/databaseSchemas.ts index 06ae608..f958207 100644 --- a/src/createDatabases/databaseSchemas.ts +++ b/src/createDatabases/databaseSchemas.ts @@ -1,11 +1,14 @@ import type { CreateDatabaseParameters } from "@notionhq/client/build/src/api-endpoints.js"; +import { SUPPORTED_ASSET_TYPES } from "../assetTypes.js"; import { NOTION_PROPERTY_NAMES } from "../notion/mapRow.js"; +import { ASSET_PROPERTY_NAMES } from "../notion/propertyNames.js"; import { SYNC_STATUSES } from "../types.js"; export const DATABASE_TITLES = { developerProduct: "Developer Products", gamePass: "Game Passes", badge: "Badges", + asset: "Assets", } as const; function buildSharedProperties(): CreateDatabaseParameters["properties"] { @@ -46,3 +49,24 @@ export function badgeDatabaseProperties(): CreateDatabaseParameters["properties" [NOTION_PROPERTY_NAMES.isActive]: { checkbox: {} }, }; } + +export function assetDatabaseProperties(): CreateDatabaseParameters["properties"] { + return { + [NOTION_PROPERTY_NAMES.name]: { title: {} }, + [NOTION_PROPERTY_NAMES.description]: { rich_text: {} }, + [ASSET_PROPERTY_NAMES.file]: { files: {} }, + [ASSET_PROPERTY_NAMES.assetType]: { + select: { + options: SUPPORTED_ASSET_TYPES.map((name) => ({ name })), + }, + }, + [NOTION_PROPERTY_NAMES.robloxId]: { number: {} }, + [NOTION_PROPERTY_NAMES.syncStatus]: { + select: { + options: SYNC_STATUSES.map((name) => ({ name })), + }, + }, + [NOTION_PROPERTY_NAMES.syncError]: { rich_text: {} }, + [NOTION_PROPERTY_NAMES.lastSyncedAt]: { date: {} }, + }; +} diff --git a/src/createDatabases/runCreateDatabases.ts b/src/createDatabases/runCreateDatabases.ts index f546f94..89c5bb6 100644 --- a/src/createDatabases/runCreateDatabases.ts +++ b/src/createDatabases/runCreateDatabases.ts @@ -36,12 +36,17 @@ const CONFIGURED_DB_FIELDS = [ valueKey: "NOTION_BADGE_DB_ID" as const, tomlKey: "badge_db_id", }, + { + valueKey: "NOTION_ASSET_DB_ID" as const, + tomlKey: "asset_db_id", + }, ] as const; const CREATED_DB_LINES = [ { label: DATABASE_TITLES.developerProduct, idKey: "devProductDbId" as const }, { label: DATABASE_TITLES.gamePass, idKey: "gamePassDbId" as const }, { label: DATABASE_TITLES.badge, idKey: "badgeDbId" as const }, + { label: DATABASE_TITLES.asset, idKey: "assetDbId" as const }, ] as const; function assertNoConfiguredDatabaseIds(config: CreateDatabasesConfig): void { diff --git a/src/init/templates.ts b/src/init/templates.ts index a7f4aad..7623cbb 100644 --- a/src/init/templates.ts +++ b/src/init/templates.ts @@ -13,17 +13,25 @@ ROBLOX_API_KEY=xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx export const TOML_TEMPLATE = `# Generated by \`ntn-roblox init\`. Safe to commit — secrets belong in .env only. [notion] -# Parent page where \`ntn-roblox create-databases\` creates the three databases +# Parent page where \`ntn-roblox create-db\` creates the databases # (32-char hex ID from the page URL). Share this page with your integration first. parent_page_id = "" -# Filled by \`ntn-roblox create-databases\`, or set manually if you create databases yourself. +# Filled by \`ntn-roblox create-db\`, or set manually if you create databases yourself. dev_product_db_id = "" game_pass_db_id = "" badge_db_id = "" +asset_db_id = "" +# is_inline = true [roblox] +# Use universe_id for a single universe, OR universes for multi-universe sync (not both). universe_id = 1234567890 +# universes = { main = 1234567890, staging = 9876543210 } # badge_payment_source = "user" +# Required when asset_db_id is configured (for asset upload ownership): +# [roblox.asset_creator] +# is_group = false +# id = 12345678 [logging] # level = "info" diff --git a/src/notion/client.ts b/src/notion/client.ts index 7ffdaa0..7bfd1eb 100644 --- a/src/notion/client.ts +++ b/src/notion/client.ts @@ -24,6 +24,8 @@ export function databaseIdForType(type: ResourceType): string { return getConfig().NOTION_GAMEPASS_DB_ID; case "badge": return getConfig().NOTION_BADGE_DB_ID; + case "asset": + return getConfig().NOTION_ASSET_DB_ID; } } diff --git a/src/notion/mapRow.ts b/src/notion/mapRow.ts index 97a46fe..0aa6492 100644 --- a/src/notion/mapRow.ts +++ b/src/notion/mapRow.ts @@ -1,5 +1,8 @@ import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints.js"; +import { isSupportedAssetType } from "../assetTypes.js"; +import { getUniverseKeys, isMultiUniverseMode } from "../config.js"; import type { + AssetRow, BadgeRow, DeveloperProductRow, GamePassRow, @@ -8,6 +11,7 @@ import type { SyncStatus, } from "../types.js"; import { SYNC_STATUSES } from "../types.js"; +import { ASSET_PROPERTY_NAMES, robloxIdPropertyName } from "./propertyNames.js"; const PROPERTY = { name: "Name", @@ -25,7 +29,7 @@ const PROPERTY = { export function mapDeveloperProductPage( page: PageObjectResponse, ): DeveloperProductRow | RowMappingError { - const base = mapBasePage(page, "developer-product"); + const base = mapMonetizationBasePage(page); if ("message" in base) { return base; } @@ -41,7 +45,7 @@ export function mapDeveloperProductPage( export function mapGamePassPage( page: PageObjectResponse, ): GamePassRow | RowMappingError { - const base = mapBasePage(page, "game-pass"); + const base = mapMonetizationBasePage(page); if ("message" in base) { return base; } @@ -57,7 +61,7 @@ export function mapGamePassPage( export function mapBadgePage( page: PageObjectResponse, ): BadgeRow | RowMappingError { - const base = mapBasePage(page, "badge"); + const base = mapMonetizationBasePage(page); if ("message" in base) { return base; } @@ -69,9 +73,61 @@ export function mapBadgePage( }; } -function mapBasePage( +export function mapAssetPage( + page: PageObjectResponse, +): AssetRow | RowMappingError { + const pageId = page.id; + + try { + const name = readTitle(page, PROPERTY.name); + if (!name) { + return { pageId, message: `Missing required property: ${PROPERTY.name}` }; + } + + const syncStatus = readSyncStatus(page, PROPERTY.syncStatus); + if (!syncStatus) { + return { + pageId, + message: `Missing or invalid ${PROPERTY.syncStatus}. Expected one of: ${SYNC_STATUSES.join(", ")}`, + }; + } + + const assetTypeRaw = readSelect(page, ASSET_PROPERTY_NAMES.assetType); + if (!assetTypeRaw) { + return { + pageId, + message: `Missing required property: ${ASSET_PROPERTY_NAMES.assetType}`, + }; + } + if (!isSupportedAssetType(assetTypeRaw)) { + return { + pageId, + message: `Invalid ${ASSET_PROPERTY_NAMES.assetType}: ${assetTypeRaw}. Expected one of: Animation, Audio, Decal, Image, Model`, + }; + } + + return { + pageId, + name, + description: readRichText(page, PROPERTY.description), + iconUrl: null, + robloxId: readOptionalNumber(page, PROPERTY.robloxId), + syncStatus, + syncError: readRichText(page, PROPERTY.syncError), + lastSyncedAt: readOptionalDate(page, PROPERTY.lastSyncedAt), + type: "asset", + assetType: assetTypeRaw, + fileUrl: readFirstFileUrl(page, ASSET_PROPERTY_NAMES.file), + }; + } catch (error) { + const message = + error instanceof Error ? error.message : "Unknown mapping error"; + return { pageId, message }; + } +} + +function mapMonetizationBasePage( page: PageObjectResponse, - _type: NotionRow["type"], ): (Omit & { type?: never }) | RowMappingError { const pageId = page.id; @@ -89,12 +145,15 @@ function mapBasePage( }; } + const robloxIds = readRobloxIds(page); + return { pageId, name, description: readRichText(page, PROPERTY.description), iconUrl: readFirstFileUrl(page, PROPERTY.icon), - robloxId: readOptionalNumber(page, PROPERTY.robloxId), + robloxId: robloxIds.robloxId, + ...(robloxIds.robloxIds !== undefined ? { robloxIds: robloxIds.robloxIds } : {}), syncStatus, syncError: readRichText(page, PROPERTY.syncError), lastSyncedAt: readOptionalDate(page, PROPERTY.lastSyncedAt), @@ -106,6 +165,26 @@ function mapBasePage( } } +function readRobloxIds(page: PageObjectResponse): { + robloxId: number | null; + robloxIds?: Record; +} { + if (isMultiUniverseMode()) { + const robloxIds: Record = {}; + for (const key of getUniverseKeys()) { + robloxIds[key] = readOptionalNumber( + page, + robloxIdPropertyName(key), + ); + } + return { robloxId: null, robloxIds }; + } + + return { + robloxId: readOptionalNumber(page, robloxIdPropertyName(null)), + }; +} + function readTitle(page: PageObjectResponse, propertyName: string): string { const property = page.properties[propertyName]; if (!property || property.type !== "title") { @@ -128,6 +207,14 @@ function readRichText(page: PageObjectResponse, propertyName: string): string { return property.rich_text.map((item) => item.plain_text).join(""); } +function readSelect(page: PageObjectResponse, propertyName: string): string | null { + const property = page.properties[propertyName]; + if (!property || property.type !== "select") { + return null; + } + return property.select?.name ?? null; +} + function readOptionalNumber( page: PageObjectResponse, propertyName: string, diff --git a/src/notion/propertyNames.ts b/src/notion/propertyNames.ts new file mode 100644 index 0000000..0001f2c --- /dev/null +++ b/src/notion/propertyNames.ts @@ -0,0 +1,21 @@ +export const UNIVERSE_KEY_PATTERN = /^[A-Za-z0-9_-]+$/; + +export function assertValidUniverseKey(key: string): void { + if (!UNIVERSE_KEY_PATTERN.test(key)) { + throw new Error( + `Invalid universe key "${key}": must match ${UNIVERSE_KEY_PATTERN}`, + ); + } +} + +export function robloxIdPropertyName(targetKey: string | null): string { + if (targetKey === null) { + return "Roblox ID"; + } + return `Roblox ID (${targetKey})`; +} + +export const ASSET_PROPERTY_NAMES = { + assetType: "Asset Type", + file: "File", +} as const; diff --git a/src/notion/schema.ts b/src/notion/schema.ts index ce081ed..4065297 100644 --- a/src/notion/schema.ts +++ b/src/notion/schema.ts @@ -12,6 +12,7 @@ const TYPE_LABELS: Record = { "developer-product": "Developer Product", "game-pass": "Game Pass", badge: "Badge", + asset: "Asset", }; export async function ensureSyncStatusSchemas( diff --git a/src/notion/writeback.ts b/src/notion/writeback.ts index 03f1496..5582fed 100644 --- a/src/notion/writeback.ts +++ b/src/notion/writeback.ts @@ -1,12 +1,78 @@ import type { UpdatePageParameters } from "@notionhq/client/build/src/api-endpoints.js"; import type { SyncStatus, WritebackPayload } from "../types.js"; import { notionRateLimiter } from "../util/rateLimit.js"; +import { robloxIdPropertyName } from "./propertyNames.js"; import { getNotionClient } from "./client.js"; import { NOTION_PROPERTY_NAMES, richTextFromString } from "./mapRow.js"; import { getSyncStatusPropertyType } from "./schema.js"; const MAX_SYNC_ERROR_LENGTH = 1900; +export interface TargetResult { + targetKey: string | null; + outcome: "created" | "updated" | "skipped" | "error"; + robloxId?: number; + message?: string; +} + +export interface AggregatedWriteback { + syncStatus: SyncStatus; + syncError: string; + robloxIds: Record; + lastSyncedAt?: string; +} + +export function aggregateTargetResults( + results: TargetResult[], +): AggregatedWriteback { + const errors: string[] = []; + const skips: string[] = []; + const robloxIds: Record = {}; + let hasSuccess = false; + + for (const result of results) { + const prefix = + result.targetKey !== null ? `[${result.targetKey}] ` : ""; + + if (result.robloxId !== undefined && result.targetKey !== null) { + robloxIds[result.targetKey] = result.robloxId; + } else if (result.robloxId !== undefined && result.targetKey === null) { + robloxIds[""] = result.robloxId; + } + + if (result.outcome === "created" || result.outcome === "updated") { + hasSuccess = true; + } else if (result.outcome === "error" && result.message) { + errors.push(`${prefix}${result.message}`); + } else if (result.outcome === "skipped" && result.message) { + skips.push(`${prefix}${result.message}`); + } + } + + if (errors.length > 0) { + return { + syncStatus: "Error", + syncError: errors.join("\n"), + robloxIds, + }; + } + + if (!hasSuccess && skips.length > 0) { + return { + syncStatus: "Skipped", + syncError: skips.join("\n"), + robloxIds, + }; + } + + return { + syncStatus: "Synced", + syncError: "", + robloxIds, + lastSyncedAt: new Date().toISOString(), + }; +} + export async function writebackPage( pageId: string, databaseId: string, @@ -28,7 +94,8 @@ export async function writebackPage( } if (payload.robloxId !== undefined) { - properties[NOTION_PROPERTY_NAMES.robloxId] = { + const targetKey = payload.robloxIdTargetKey ?? null; + properties[robloxIdPropertyName(targetKey)] = { number: payload.robloxId, }; } @@ -52,13 +119,14 @@ export async function writebackRobloxId( pageId: string, _databaseId: string, robloxId: number, + targetKey: string | null = null, ): Promise { const client = getNotionClient(); await notionRateLimiter.schedule(() => client.pages.update({ page_id: pageId, properties: { - [NOTION_PROPERTY_NAMES.robloxId]: { + [robloxIdPropertyName(targetKey)]: { number: robloxId, }, }, @@ -66,15 +134,54 @@ export async function writebackRobloxId( ); } +export async function writebackAggregatedTargetResults( + pageId: string, + databaseId: string, + aggregated: AggregatedWriteback, +): Promise { + const properties: NonNullable = { + [NOTION_PROPERTY_NAMES.syncStatus]: buildSyncStatusProperty( + databaseId, + aggregated.syncStatus, + ), + [NOTION_PROPERTY_NAMES.syncError]: { + rich_text: richTextFromString( + truncate(aggregated.syncError, MAX_SYNC_ERROR_LENGTH), + ), + }, + }; + + for (const [key, robloxId] of Object.entries(aggregated.robloxIds)) { + const targetKey = key === "" ? null : key; + properties[robloxIdPropertyName(targetKey)] = { number: robloxId }; + } + + if (aggregated.lastSyncedAt !== undefined) { + properties[NOTION_PROPERTY_NAMES.lastSyncedAt] = { + date: { start: aggregated.lastSyncedAt }, + }; + } + + const client = getNotionClient(); + await notionRateLimiter.schedule(() => + client.pages.update({ + page_id: pageId, + properties, + }), + ); +} + export async function writebackSuccess( pageId: string, databaseId: string, robloxId: number, + targetKey: string | null = null, ): Promise { await writebackPage(pageId, databaseId, { syncStatus: "Synced", syncError: "", robloxId, + robloxIdTargetKey: targetKey, lastSyncedAt: new Date().toISOString(), }); } @@ -84,11 +191,14 @@ export async function writebackError( databaseId: string, message: string, robloxId?: number, + targetKey: string | null = null, ): Promise { await writebackPage(pageId, databaseId, { syncStatus: "Error", syncError: message, - ...(robloxId !== undefined ? { robloxId } : {}), + ...(robloxId !== undefined + ? { robloxId, robloxIdTargetKey: targetKey } + : {}), }); } diff --git a/src/roblox/assets.ts b/src/roblox/assets.ts new file mode 100644 index 0000000..266f273 --- /dev/null +++ b/src/roblox/assets.ts @@ -0,0 +1,190 @@ +import { getConfig, type RobloxAssetCreator } from "../config.js"; +import type { FileUpload } from "../types.js"; +import { robloxJson, robloxMultipart } from "./http.js"; + +const CREATE_PATH = "/assets/v1/assets"; +const POLL_TIMEOUT_MS = 120_000; +const POLL_INTERVAL_MS = 2_000; + +export interface CreateAssetInput { + assetType: string; + displayName: string; + description: string; + file: FileUpload; +} + +export interface UpdateAssetMetadataInput { + displayName: string; + description: string; +} + +interface AssetOperation { + path: string; + done?: boolean; + error?: { code?: number; message?: string }; + response?: { + path?: string; + assetId?: string | number; + }; +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function getAssetCreator(): RobloxAssetCreator { + const creator = getConfig().ROBLOX_ASSET_CREATOR; + if (!creator) { + throw new Error( + "roblox.asset_creator is required in ntn-roblox.toml (is_group, id)", + ); + } + return creator; +} + +function buildCreationContext() { + const creator = getAssetCreator(); + return { + creator: creator.is_group + ? { groupId: String(creator.id) } + : { userId: String(creator.id) }, + expectedPrice: 0, + }; +} + +export function mimeTypeFromFilename(filename: string): string { + const lower = filename.toLowerCase(); + if (lower.endsWith(".png")) { + return "image/png"; + } + if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { + return "image/jpeg"; + } + if (lower.endsWith(".bmp")) { + return "image/bmp"; + } + if (lower.endsWith(".tga")) { + return "image/tga"; + } + if (lower.endsWith(".mp3")) { + return "audio/mpeg"; + } + if (lower.endsWith(".ogg")) { + return "audio/ogg"; + } + if (lower.endsWith(".wav")) { + return "audio/wav"; + } + if (lower.endsWith(".flac")) { + return "audio/flac"; + } + if (lower.endsWith(".fbx")) { + return "model/fbx"; + } + if (lower.endsWith(".gltf")) { + return "model/gltf+json"; + } + if (lower.endsWith(".glb")) { + return "model/gltf-binary"; + } + if (lower.endsWith(".rbxm") || lower.endsWith(".rbxmx")) { + return "model/x-rbxm"; + } + return "application/octet-stream"; +} + +function parseAssetIdFromPath(path: string): number | null { + const match = path.match(/^assets\/(\d+)$/); + if (!match) { + return null; + } + return Number(match[1]); +} + +function parseAssetIdFromOperation(operation: AssetOperation): number { + const responsePath = operation.response?.path; + if (responsePath) { + const assetId = parseAssetIdFromPath(responsePath); + if (assetId !== null) { + return assetId; + } + } + + if (operation.response?.assetId !== undefined) { + return Number(operation.response.assetId); + } + + throw new Error("Asset operation completed without asset ID"); +} + +async function pollAssetOperation(operationPath: string): Promise { + const deadline = Date.now() + POLL_TIMEOUT_MS; + + while (Date.now() < deadline) { + const operation = await robloxJson({ + path: `/assets/v1/${operationPath}`, + }); + + if (operation.error?.message) { + throw new Error(`Asset operation failed: ${operation.error.message}`); + } + + if (operation.done) { + return parseAssetIdFromOperation(operation); + } + + await sleep(POLL_INTERVAL_MS); + } + + throw new Error( + `Asset operation timed out after ${POLL_TIMEOUT_MS / 1000}s (${operationPath})`, + ); +} + +export async function createAsset(input: CreateAssetInput): Promise { + const request = { + assetType: input.assetType, + displayName: input.displayName, + description: input.description, + creationContext: buildCreationContext(), + }; + + const operation = await robloxMultipart({ + method: "POST", + path: CREATE_PATH, + fields: [{ name: "request", value: JSON.stringify(request) }], + file: { + ...input.file, + mimeType: + input.file.mimeType || mimeTypeFromFilename(input.file.filename), + fieldName: "fileContent", + }, + }); + + if (operation.done) { + return parseAssetIdFromOperation(operation); + } + + if (!operation.path) { + throw new Error("Asset create response missing operation path"); + } + + return pollAssetOperation(operation.path); +} + +export async function updateAssetMetadata( + assetId: number, + input: UpdateAssetMetadataInput, +): Promise { + const request = { + assetId, + displayName: input.displayName, + description: input.description, + }; + + await robloxMultipart({ + method: "PATCH", + path: `/assets/v1/assets/${assetId}?updateMask=description,displayName`, + fields: [{ name: "request", value: JSON.stringify(request) }], + }); +} diff --git a/src/sync/candidates.ts b/src/sync/candidates.ts index a700c26..3d2d2fb 100644 --- a/src/sync/candidates.ts +++ b/src/sync/candidates.ts @@ -1,4 +1,4 @@ -import type { NotionRow, SyncStatus } from "../types.js"; +import type { NotionRow } from "../types.js"; export type SyncAction = "create" | "update" | "skip"; @@ -8,42 +8,38 @@ export interface SyncCandidate { reason?: string; } -const UPDATE_STATUSES: SyncStatus[] = ["Pending", "Error", "Skipped"]; +export type ClassifyMode = "sync" | "update"; export interface ClassifyOptions { - force?: boolean; + mode: ClassifyMode; + robloxIdForTarget?: number | null; } export function classifyRow( row: NotionRow, - options: ClassifyOptions = {}, + options: ClassifyOptions, ): SyncCandidate { - const force = options.force ?? false; + const robloxId = + options.robloxIdForTarget !== undefined + ? options.robloxIdForTarget + : row.robloxId; - if (row.syncStatus === "Synced") { - if (!force) { - return { row, action: "skip", reason: "Sync Status is Synced" }; - } - if (row.robloxId === null) { + if (options.mode === "sync") { + if (robloxId === null) { return { row, action: "create" }; } - return { row, action: "update" }; - } - - if (row.robloxId === null) { - return { row, action: "create" }; + return { row, action: "skip", reason: "Roblox ID already set" }; } - if (UPDATE_STATUSES.includes(row.syncStatus)) { - return { row, action: "update" }; + if (robloxId === null) { + return { row, action: "skip", reason: "Roblox ID is empty" }; } - - return { row, action: "skip", reason: `Unhandled Sync Status: ${row.syncStatus}` }; + return { row, action: "update" }; } export function classifyRows( rows: NotionRow[], - options: ClassifyOptions = {}, + options: ClassifyOptions, ): SyncCandidate[] { return rows.map((row) => classifyRow(row, options)); } diff --git a/src/sync/engine.ts b/src/sync/engine.ts index fde5a05..3a0f3fb 100644 --- a/src/sync/engine.ts +++ b/src/sync/engine.ts @@ -1,17 +1,27 @@ +import { + isMultiUniverseMode, + resolveSyncTargets, + setActiveUniverse, +} from "../config.js"; import { databaseIdForType, queryAllPagesForType } from "../notion/client.js"; import { isMappingError, + mapAssetPage, mapBadgePage, mapDeveloperProductPage, mapGamePassPage, } from "../notion/mapRow.js"; import { + aggregateTargetResults, + writebackAggregatedTargetResults, writebackError, writebackRobloxId, writebackSkipped, writebackSuccess, + type TargetResult, } from "../notion/writeback.js"; import { ensureSyncStatusSchemas } from "../notion/schema.js"; +import { createAsset, updateAssetMetadata } from "../roblox/assets.js"; import { RobloxHttpError } from "../roblox/http.js"; import { BadgeQuotaExhaustedError, @@ -34,16 +44,20 @@ import { updateGamePass, } from "../roblox/gamePasses.js"; import type { + AssetRow, FileUpload, NotionRow, ResourceType, RowMappingError, } from "../types.js"; -import { downloadFile } from "../util/download.js"; +import { + downloadFile, + MAX_ASSET_DOWNLOAD_BYTES, +} from "../util/download.js"; import { logger } from "../util/logger.js"; import { sanitizeErrorMessage } from "../util/sanitizeError.js"; import { - classifyRows, + classifyRow, filterActionable, type SyncCandidate, } from "./candidates.js"; @@ -55,12 +69,14 @@ import { type OrphanItem, type OrphanReportSection, } from "./orphanReport.js"; +import { getRobloxIdForTarget } from "./robloxIds.js"; export interface SyncOptions { + mode: "sync" | "update"; dryRun: boolean; reportOnly: boolean; - force: boolean; typeFilter?: ResourceType; + targetFilter?: string; } export interface SyncResult { @@ -72,25 +88,39 @@ export interface SyncResult { orphans: number; } -const ALL_TYPES: ResourceType[] = ["developer-product", "game-pass", "badge"]; +const ALL_TYPES: ResourceType[] = [ + "developer-product", + "game-pass", + "badge", + "asset", +]; + +const MONETIZATION_TYPES: ResourceType[] = [ + "developer-product", + "game-pass", + "badge", +]; const TYPE_LABELS: Record = { "developer-product": "Developer Product", "game-pass": "Game Pass", badge: "Badge", + asset: "Asset", }; interface TypeSyncContext { type: ResourceType; rows: NotionRow[]; mappingErrors: RowMappingError[]; - candidates: SyncCandidate[]; } export async function runSync(options: SyncOptions): Promise { const types = resolveTypes(options.typeFilter); await ensureSyncStatusSchemas(types); + const targets = resolveSyncTargets(options.targetFilter); + const skipWriteback = options.dryRun || options.reportOnly; + const result: SyncResult = { created: 0, updated: 0, @@ -101,8 +131,8 @@ export async function runSync(options: SyncOptions): Promise { }; const orphanSections: OrphanReportSection[] = []; - const typeContexts: TypeSyncContext[] = []; - const skipWriteback = options.dryRun || options.reportOnly; + const monetizationContexts: TypeSyncContext[] = []; + let assetContext: TypeSyncContext | undefined; for (const type of types) { logger.info(`Loading ${TYPE_LABELS[type]}...`); @@ -115,19 +145,23 @@ export async function runSync(options: SyncOptions): Promise { result.errors += 1; } - const robloxItems = await listRobloxItemsForOrphans(type); - const notionIds = collectNotionRobloxIds(rows); - orphanSections.push({ - type, - orphans: findOrphans(robloxItems, notionIds), - }); - - typeContexts.push({ - type, - rows, - mappingErrors, - candidates: filterActionable(classifyRows(rows, { force: options.force })), - }); + if (type === "asset") { + assetContext = { type, rows, mappingErrors }; + continue; + } + + for (const target of targets) { + setActiveUniverse(target.key); + + const robloxItems = await listRobloxItemsForOrphans(type); + const notionIds = collectNotionRobloxIds(rows, target.key); + orphanSections.push({ + type, + orphans: findOrphans(robloxItems, notionIds), + }); + } + + monetizationContexts.push({ type, rows, mappingErrors }); } result.orphans = countOrphans(orphanSections); @@ -140,42 +174,113 @@ export async function runSync(options: SyncOptions): Promise { let badgeQuota: number | undefined; - for (const context of typeContexts) { - const { type, candidates } = context; + for (const context of monetizationContexts) { + const { type, rows } = context; logger.info(`Syncing ${TYPE_LABELS[type]}...`); - if (candidates.length === 0) { - logger.info(`No actionable rows for ${TYPE_LABELS[type]}`); - continue; - } + if (isMultiUniverseMode()) { + const rowResults = new Map(); + + for (const target of targets) { + setActiveUniverse(target.key); + + const candidates = buildCandidates(rows, options.mode, target.key); + if (candidates.length === 0) { + continue; + } + + if (type === "badge" && badgeQuota === undefined) { + badgeQuota = await getFreeBadgeQuota(); + logger.info(`Badge free quota remaining: ${badgeQuota}`); + } + + for (const candidate of candidates) { + const rowResult = await processCandidate(candidate, { + dryRun: options.dryRun, + badgeQuota, + targetKey: target.key, + deferWriteback: true, + }); + + applyRowResult(result, rowResult); + + if ( + type === "badge" && + badgeQuota !== undefined && + rowResult.consumedBadgeQuota + ) { + badgeQuota -= 1; + } + + const targetOutcome = toTargetResult( + target.key, + rowResult, + candidate.reason, + ); + const existing = rowResults.get(candidate.row.pageId) ?? []; + existing.push(targetOutcome); + rowResults.set(candidate.row.pageId, existing); + } + } - if (type === "badge" && badgeQuota === undefined) { - badgeQuota = await getFreeBadgeQuota(); - logger.info(`Badge free quota remaining: ${badgeQuota}`); - } + if (!options.dryRun) { + for (const [pageId, targetResults] of rowResults) { + const aggregated = aggregateTargetResults(targetResults); + await writebackAggregatedTargetResults( + pageId, + databaseIdForType(type), + aggregated, + ); + } + } + } else { + const target = targets[0]!; + setActiveUniverse(target.key); - for (const candidate of candidates) { - const rowResult = await processCandidate(candidate, { - dryRun: options.dryRun, - badgeQuota, - }); + const candidates = buildCandidates(rows, options.mode, target.key); + if (candidates.length === 0) { + logger.info(`No actionable rows for ${TYPE_LABELS[type]}`); + continue; + } - if (rowResult.outcome === "created") { - result.created += 1; - } else if (rowResult.outcome === "updated") { - result.updated += 1; - } else if (rowResult.outcome === "skipped") { - result.skipped += 1; - } else { - result.errors += 1; + if (type === "badge" && badgeQuota === undefined) { + badgeQuota = await getFreeBadgeQuota(); + logger.info(`Badge free quota remaining: ${badgeQuota}`); } - if ( - type === "badge" && - badgeQuota !== undefined && - rowResult.consumedBadgeQuota - ) { - badgeQuota -= 1; + for (const candidate of candidates) { + const rowResult = await processCandidate(candidate, { + dryRun: options.dryRun, + badgeQuota, + targetKey: target.key, + }); + + applyRowResult(result, rowResult); + + if ( + type === "badge" && + badgeQuota !== undefined && + rowResult.consumedBadgeQuota + ) { + badgeQuota -= 1; + } + } + } + } + + if (assetContext && types.includes("asset")) { + logger.info(`Syncing ${TYPE_LABELS.asset}...`); + const candidates = buildCandidates(assetContext.rows, options.mode, null); + + if (candidates.length === 0) { + logger.info(`No actionable rows for ${TYPE_LABELS.asset}`); + } else { + for (const candidate of candidates) { + const rowResult = await processCandidate(candidate, { + dryRun: options.dryRun, + targetKey: null, + }); + applyRowResult(result, rowResult); } } } @@ -184,8 +289,72 @@ export async function runSync(options: SyncOptions): Promise { return result; } +function buildCandidates( + rows: NotionRow[], + mode: SyncOptions["mode"], + targetKey: string | null, +): SyncCandidate[] { + return filterActionable( + rows.map((row) => + classifyRow(row, { + mode, + robloxIdForTarget: getRobloxIdForTarget(row, targetKey), + }), + ), + ); +} + +function toTargetResult( + targetKey: string | null, + rowResult: ProcessCandidateResult, + skipReason?: string, +): TargetResult { + if (rowResult.outcome === "created") { + return { + targetKey, + outcome: "created", + robloxId: rowResult.robloxId, + }; + } + if (rowResult.outcome === "updated") { + return { + targetKey, + outcome: "updated", + robloxId: rowResult.robloxId, + }; + } + if (rowResult.outcome === "skipped") { + return { + targetKey, + outcome: "skipped", + message: rowResult.message ?? skipReason, + }; + } + return { + targetKey, + outcome: "error", + message: rowResult.message, + robloxId: rowResult.robloxId, + }; +} + +function applyRowResult(result: SyncResult, rowResult: ProcessCandidateResult): void { + if (rowResult.outcome === "created") { + result.created += 1; + } else if (rowResult.outcome === "updated") { + result.updated += 1; + } else if (rowResult.outcome === "skipped") { + result.skipped += 1; + } else { + result.errors += 1; + } +} + function resolveTypes(typeFilter?: ResourceType): ResourceType[] { - return typeFilter ? [typeFilter] : ALL_TYPES; + if (typeFilter) { + return [typeFilter]; + } + return ALL_TYPES; } async function loadNotionRows(type: ResourceType): Promise<{ @@ -219,6 +388,8 @@ function mapPage( return mapGamePassPage(page); case "badge": return mapBadgePage(page); + case "asset": + return mapAssetPage(page); } } @@ -246,6 +417,8 @@ async function listRobloxItemsForOrphans( name: item.name, enabled: item.enabled, })); + case "asset": + return []; } } @@ -254,11 +427,15 @@ type RowOutcome = "created" | "updated" | "skipped" | "error"; export interface ProcessCandidateResult { outcome: RowOutcome; consumedBadgeQuota?: boolean; + robloxId?: number; + message?: string; } interface ProcessContext { dryRun: boolean; badgeQuota?: number; + targetKey: string | null; + deferWriteback?: boolean; } export async function processCandidate( @@ -279,18 +456,24 @@ export async function processCandidate( const reason = error.message; warnQuotaExhausted(row.name); logger.warn(`${label}: ${reason}`); - if (!context.dryRun) { + if (!context.dryRun && !context.deferWriteback) { await writebackSkipped(row.pageId, databaseId, reason); } - return { outcome: "skipped" }; + return { outcome: "skipped", message: reason }; } const message = formatError(error); logger.error(`${label}: ${message}`); - if (!context.dryRun) { - await writebackError(row.pageId, databaseId, message); + if (!context.dryRun && !context.deferWriteback) { + await writebackError( + row.pageId, + databaseId, + message, + undefined, + context.targetKey, + ); } - return { outcome: "error" }; + return { outcome: "error", message }; } } @@ -300,43 +483,78 @@ async function processCreate( context: ProcessContext, ): Promise { const databaseId = databaseIdForType(row.type); + + if (row.type === "asset" && !row.fileUrl) { + const reason = "File is required for asset create"; + logger.error(`${label}: ${reason}`); + if (!context.dryRun && !context.deferWriteback) { + await writebackError(row.pageId, databaseId, reason); + } + return { outcome: "error", message: reason }; + } + if (row.type === "badge") { const quota = context.badgeQuota ?? 0; if (quota <= 0) { const reason = "Badge free quota exhausted"; warnQuotaExhausted(row.name); logger.warn(`${label}: ${reason}`); - if (!context.dryRun) { + if (!context.dryRun && !context.deferWriteback) { await writebackSkipped(row.pageId, databaseId, reason); } - return { outcome: "skipped" }; + return { outcome: "skipped", message: reason }; } } - if (row.type !== "badge" && row.price === null) { + if ( + row.type !== "badge" && + row.type !== "asset" && + row.price === null + ) { const reason = "Price is required for create"; logger.error(`${label}: ${reason}`); - if (!context.dryRun) { - await writebackError(row.pageId, databaseId, reason); + if (!context.dryRun && !context.deferWriteback) { + await writebackError( + row.pageId, + databaseId, + reason, + undefined, + context.targetKey, + ); } - return { outcome: "error" }; + return { outcome: "error", message: reason }; } - const iconHint = row.iconUrl ? "yes" : "no"; + const iconHint = row.type === "asset" ? "n/a" : row.iconUrl ? "yes" : "no"; if (context.dryRun) { logger.info(`[DRY-RUN] CREATE ${label} (icon=${iconHint})`); return { outcome: "created" }; } - const icon = await resolveIcon(row.iconUrl); + const icon = + row.type === "asset" ? undefined : await resolveIcon(row.iconUrl); const robloxId = await createRobloxItem(row, icon, context.badgeQuota ?? 0); const consumedBadgeQuota = row.type === "badge"; + if (context.deferWriteback) { + logger.info(`Created ${label} → Roblox ID ${robloxId}`); + return { + outcome: "created", + robloxId, + ...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}), + }; + } + let idWritebackOk = true; try { - await writebackRobloxId(row.pageId, databaseId, robloxId); + await writebackRobloxId( + row.pageId, + databaseId, + robloxId, + context.targetKey, + ); } catch (error) { idWritebackOk = false; logger.error( @@ -347,10 +565,16 @@ async function processCreate( let robloxIdPersisted = idWritebackOk; try { - await writebackSuccess(row.pageId, databaseId, robloxId); + await writebackSuccess( + row.pageId, + databaseId, + robloxId, + context.targetKey, + ); logger.info(`Created ${label} → Roblox ID ${robloxId}`); return { outcome: "created", + robloxId, ...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}), }; } catch (error) { @@ -358,7 +582,13 @@ async function processCreate( logger.error(`${label}: Sync success writeback failed: ${message}`); try { - await writebackError(row.pageId, databaseId, message, robloxId); + await writebackError( + row.pageId, + databaseId, + message, + robloxId, + context.targetKey, + ); robloxIdPersisted = true; } catch (writebackErr) { logger.error( @@ -376,6 +606,8 @@ async function processCreate( return { outcome: "error", + message, + robloxId, ...(consumedBadgeQuota ? { consumedBadgeQuota: true } : {}), }; } @@ -387,30 +619,50 @@ async function processUpdate( context: ProcessContext, ): Promise { const databaseId = databaseIdForType(row.type); - if (row.robloxId === null) { + const robloxId = getRobloxIdForTarget(row, context.targetKey); + + if (robloxId === null) { const reason = "Roblox ID is required for update"; logger.error(`${label}: ${reason}`); - if (!context.dryRun) { - await writebackError(row.pageId, databaseId, reason); + if (!context.dryRun && !context.deferWriteback) { + await writebackError( + row.pageId, + databaseId, + reason, + undefined, + context.targetKey, + ); } - return { outcome: "error" }; + return { outcome: "error", message: reason }; } - const iconHint = row.iconUrl ? "yes" : "no"; + const iconHint = row.type === "asset" ? "n/a" : row.iconUrl ? "yes" : "no"; if (context.dryRun) { logger.info( - `[DRY-RUN] UPDATE ${label} (Roblox ID ${row.robloxId}, icon=${iconHint})`, + `[DRY-RUN] UPDATE ${label} (Roblox ID ${robloxId}, icon=${iconHint})`, ); - return { outcome: "updated" }; + return { outcome: "updated", robloxId }; } - const icon = await resolveIcon(row.iconUrl); + const icon = + row.type === "asset" ? undefined : await resolveIcon(row.iconUrl); - await updateRobloxItem(row, icon); - await writebackSuccess(row.pageId, databaseId, row.robloxId); - logger.info(`Updated ${label} (Roblox ID ${row.robloxId})`); - return { outcome: "updated" }; + await updateRobloxItem(row, icon, robloxId); + + if (context.deferWriteback) { + logger.info(`Updated ${label} (Roblox ID ${robloxId})`); + return { outcome: "updated", robloxId }; + } + + await writebackSuccess( + row.pageId, + databaseId, + robloxId, + context.targetKey, + ); + logger.info(`Updated ${label} (Roblox ID ${robloxId})`); + return { outcome: "updated", robloxId }; } async function createRobloxItem( @@ -445,15 +697,30 @@ async function createRobloxItem( }, badgeQuota, ); + case "asset": + return createAssetFromRow(row); } } +async function createAssetFromRow(row: AssetRow): Promise { + const file = await downloadFile( + row.fileUrl as string, + undefined, + MAX_ASSET_DOWNLOAD_BYTES, + ); + return createAsset({ + assetType: row.assetType, + displayName: row.name, + description: row.description, + file, + }); +} + async function updateRobloxItem( row: NotionRow, icon: FileUpload | undefined, + robloxId: number, ): Promise { - const robloxId = row.robloxId as number; - switch (row.type) { case "developer-product": await updateDeveloperProduct(robloxId, { @@ -481,6 +748,12 @@ async function updateRobloxItem( icon, }); return; + case "asset": + await updateAssetMetadata(robloxId, { + displayName: row.name, + description: row.description, + }); + return; } } @@ -535,10 +808,12 @@ function logSummary(result: SyncResult, options: SyncOptions): void { ? "report-only" : options.dryRun ? "dry-run" - : "sync"; + : options.mode; logger.info(`Finished (${mode}): ${parts.join(", ")}`); } export function shouldExitWithError(result: SyncResult): boolean { return result.errors > 0 || result.skipped > 0; } + +export { MONETIZATION_TYPES }; diff --git a/src/sync/orphanReport.ts b/src/sync/orphanReport.ts index b1e200f..a815948 100644 --- a/src/sync/orphanReport.ts +++ b/src/sync/orphanReport.ts @@ -17,13 +17,22 @@ const TYPE_LABELS: Record = { "developer-product": "Developer Product", "game-pass": "Game Pass", badge: "Badge", + asset: "Asset", }; export function collectNotionRobloxIds( - rows: Array<{ robloxId: number | null }>, + rows: Array<{ robloxId: number | null; robloxIds?: Record }>, + targetKey: string | null = null, ): Set { const ids = new Set(); for (const row of rows) { + if (targetKey !== null && row.robloxIds) { + const id = row.robloxIds[targetKey]; + if (id !== null && id !== undefined) { + ids.add(id); + } + continue; + } if (row.robloxId !== null) { ids.add(row.robloxId); } @@ -45,15 +54,16 @@ export function countOrphans(sections: OrphanReportSection[]): number { } export function printOrphanReport(sections: OrphanReportSection[]): void { - const totalOrphans = countOrphans(sections); + const filteredSections = sections.filter((section) => section.type !== "asset"); + const totalOrphans = countOrphans(filteredSections); console.log(""); console.log("Roblox items not referenced in Notion"); console.log(`Total: ${totalOrphans}`); console.log(""); - sections.forEach((section, index) => { - const isLast = index === sections.length - 1; + filteredSections.forEach((section, index) => { + const isLast = index === filteredSections.length - 1; const branch = isLast ? "└──" : "├──"; const childPrefix = isLast ? " " : "│ "; const label = TYPE_LABELS[section.type]; diff --git a/src/sync/robloxIds.ts b/src/sync/robloxIds.ts new file mode 100644 index 0000000..c4bb38d --- /dev/null +++ b/src/sync/robloxIds.ts @@ -0,0 +1,25 @@ +import type { NotionRowBase } from "../types.js"; + +export function getRobloxIdForTarget( + row: NotionRowBase, + targetKey: string | null, +): number | null { + if (targetKey === null) { + return row.robloxId; + } + return row.robloxIds?.[targetKey] ?? null; +} + +export function collectRobloxIdsForTarget( + rows: NotionRowBase[], + targetKey: string | null, +): Set { + const ids = new Set(); + for (const row of rows) { + const id = getRobloxIdForTarget(row, targetKey); + if (id !== null) { + ids.add(id); + } + } + return ids; +} diff --git a/src/tomlWrite.ts b/src/tomlWrite.ts index 3ea50ff..def5250 100644 --- a/src/tomlWrite.ts +++ b/src/tomlWrite.ts @@ -2,6 +2,7 @@ export type NotionDatabaseIds = { devProductDbId: string; gamePassDbId: string; badgeDbId: string; + assetDbId: string; }; const NOTION_DB_KEYS: Array<{ @@ -11,6 +12,7 @@ const NOTION_DB_KEYS: Array<{ { field: "devProductDbId", tomlKey: "dev_product_db_id" }, { field: "gamePassDbId", tomlKey: "game_pass_db_id" }, { field: "badgeDbId", tomlKey: "badge_db_id" }, + { field: "assetDbId", tomlKey: "asset_db_id" }, ]; function escapeRegExp(value: string): string { diff --git a/src/types.ts b/src/types.ts index e26c647..37a9287 100644 --- a/src/types.ts +++ b/src/types.ts @@ -1,4 +1,10 @@ -export type ResourceType = "developer-product" | "game-pass" | "badge"; +import type { SupportedAssetType } from "./assetTypes.js"; + +export type ResourceType = + | "developer-product" + | "game-pass" + | "badge" + | "asset"; export const SYNC_STATUSES = ["Pending", "Synced", "Error", "Skipped"] as const; export type SyncStatus = (typeof SYNC_STATUSES)[number]; @@ -9,6 +15,7 @@ export interface NotionRowBase { description: string; iconUrl: string | null; robloxId: number | null; + robloxIds?: Record; syncStatus: SyncStatus; syncError: string; lastSyncedAt: string | null; @@ -31,7 +38,17 @@ export interface BadgeRow extends NotionRowBase { isActive: boolean; } -export type NotionRow = DeveloperProductRow | GamePassRow | BadgeRow; +export interface AssetRow extends NotionRowBase { + type: "asset"; + assetType: SupportedAssetType; + fileUrl: string | null; +} + +export type NotionRow = + | DeveloperProductRow + | GamePassRow + | BadgeRow + | AssetRow; export interface RowMappingError { pageId: string; @@ -42,6 +59,7 @@ export interface WritebackPayload { syncStatus: SyncStatus; syncError?: string; robloxId?: number; + robloxIdTargetKey?: string | null; lastSyncedAt?: string; } @@ -129,6 +147,27 @@ export interface FileUpload { mimeType: string; } +export interface RobloxAsset { + assetId: number; + name: string; + assetType: SupportedAssetType; +} + +export interface AssetInput { + name: string; + description?: string; + assetType: SupportedAssetType; + file: FileUpload; +} + +export interface AssetUpdateInput { + name?: string; + description?: string; + file?: FileUpload; +} + +export type RobloxAssetOperation = "create" | "update"; + export function extractDefaultPrice( priceInformation: RobloxPriceInformation | undefined, ): number | null { diff --git a/src/util/download.ts b/src/util/download.ts index 5285549..30dc2ac 100644 --- a/src/util/download.ts +++ b/src/util/download.ts @@ -5,6 +5,8 @@ import { validateDownloadUrl, } from "./urlSafety.js"; +export const MAX_ASSET_DOWNLOAD_BYTES = 20 * 1024 * 1024; + const DEFAULT_TIMEOUT_MS = 30_000; export interface DownloadedFile { @@ -16,6 +18,7 @@ export interface DownloadedFile { export async function downloadFile( url: string, timeoutMs = DEFAULT_TIMEOUT_MS, + maxBytes = MAX_DOWNLOAD_BYTES, ): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -31,10 +34,7 @@ export async function downloadFile( ); } - const buffer = await readResponseBodyLimited( - response, - MAX_DOWNLOAD_BYTES, - ); + const buffer = await readResponseBodyLimited(response, maxBytes); const filename = filenameFromUrl(finalUrl); const mimeType = response.headers.get("content-type")?.split(";")[0]?.trim() || @@ -145,15 +145,48 @@ function filenameFromUrl(url: string): string { function guessMimeType(filename: string): string { const lower = filename.toLowerCase(); + if (lower.endsWith(".png")) { + return "image/png"; + } if (lower.endsWith(".jpg") || lower.endsWith(".jpeg")) { return "image/jpeg"; } + if (lower.endsWith(".bmp")) { + return "image/bmp"; + } + if (lower.endsWith(".tga")) { + return "image/tga"; + } if (lower.endsWith(".webp")) { return "image/webp"; } if (lower.endsWith(".gif")) { return "image/gif"; } + if (lower.endsWith(".mp3")) { + return "audio/mpeg"; + } + if (lower.endsWith(".ogg")) { + return "audio/ogg"; + } + if (lower.endsWith(".wav")) { + return "audio/wav"; + } + if (lower.endsWith(".flac")) { + return "audio/flac"; + } + if (lower.endsWith(".fbx")) { + return "model/fbx"; + } + if (lower.endsWith(".gltf")) { + return "model/gltf+json"; + } + if (lower.endsWith(".glb")) { + return "model/gltf-binary"; + } + if (lower.endsWith(".rbxm") || lower.endsWith(".rbxmx")) { + return "model/x-rbxm"; + } return "image/png"; } diff --git a/src/version.ts b/src/version.ts new file mode 100644 index 0000000..2ee8a71 --- /dev/null +++ b/src/version.ts @@ -0,0 +1,19 @@ +declare const NTN_ROBLOX_VERSION: string | undefined; + +import { createRequire } from "node:module"; + +const require = createRequire(import.meta.url); + +let VERSION = "0.0.0-dev"; + +if (typeof NTN_ROBLOX_VERSION === "string" && NTN_ROBLOX_VERSION.length > 0) { + VERSION = NTN_ROBLOX_VERSION; +} else { + try { + VERSION = require("../package.json").version; + } catch { + // keep fallback + } +} + +export { VERSION }; diff --git a/test/candidates.test.ts b/test/candidates.test.ts index 27ffb65..60b1aaf 100644 --- a/test/candidates.test.ts +++ b/test/candidates.test.ts @@ -4,34 +4,49 @@ import { classifyRow } from "../src/sync/candidates.js"; import { makeRow } from "./helpers.js"; describe("classifyRow", () => { - it("skips Synced rows when force is false", () => { - const row = makeRow({ syncStatus: "Synced", robloxId: 123 }); - const result = classifyRow(row); - assert.equal(result.action, "skip"); - assert.match(result.reason ?? "", /Synced/); - }); + describe("sync mode", () => { + it("creates rows without robloxId", () => { + const row = makeRow({ syncStatus: "Pending", robloxId: null }); + assert.equal(classifyRow(row, { mode: "sync" }).action, "create"); + }); - it("creates Synced rows without robloxId when force is true", () => { - const row = makeRow({ syncStatus: "Synced", robloxId: null }); - const result = classifyRow(row, { force: true }); - assert.equal(result.action, "create"); - }); + it("skips rows with robloxId set regardless of Sync Status", () => { + const row = makeRow({ syncStatus: "Synced", robloxId: 123 }); + const result = classifyRow(row, { mode: "sync" }); + assert.equal(result.action, "skip"); + assert.match(result.reason ?? "", /already set/); + }); - it("updates Synced rows with robloxId when force is true", () => { - const row = makeRow({ syncStatus: "Synced", robloxId: 456 }); - const result = classifyRow(row, { force: true }); - assert.equal(result.action, "update"); + it("uses robloxIdForTarget when provided", () => { + const row = makeRow({ robloxId: null, robloxIds: { main: 456 } }); + const result = classifyRow(row, { + mode: "sync", + robloxIdForTarget: 456, + }); + assert.equal(result.action, "skip"); + }); }); - it("creates rows without robloxId", () => { - const row = makeRow({ syncStatus: "Pending", robloxId: null }); - assert.equal(classifyRow(row).action, "create"); - }); + describe("update mode", () => { + it("updates rows with robloxId", () => { + const row = makeRow({ syncStatus: "Pending", robloxId: 789 }); + assert.equal(classifyRow(row, { mode: "update" }).action, "update"); + }); - for (const syncStatus of ["Pending", "Error", "Skipped"] as const) { - it(`updates rows with robloxId when syncStatus is ${syncStatus}`, () => { - const row = makeRow({ syncStatus, robloxId: 789 }); - assert.equal(classifyRow(row).action, "update"); + it("skips rows without robloxId regardless of Sync Status", () => { + const row = makeRow({ syncStatus: "Synced", robloxId: null }); + const result = classifyRow(row, { mode: "update" }); + assert.equal(result.action, "skip"); + assert.match(result.reason ?? "", /empty/); }); - } + + it("uses robloxIdForTarget when provided", () => { + const row = makeRow({ robloxId: 999, robloxIds: { main: null } }); + const result = classifyRow(row, { + mode: "update", + robloxIdForTarget: null, + }); + assert.equal(result.action, "skip"); + }); + }); }); diff --git a/test/cli.test.ts b/test/cli.test.ts new file mode 100644 index 0000000..dfdb4fb --- /dev/null +++ b/test/cli.test.ts @@ -0,0 +1,42 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { parseArgs, printUsage, VERSION } from "../src/cli.js"; + +describe("cli", () => { + it("prints help to stdout", () => { + let output = ""; + const originalLog = console.log; + console.log = (...args: unknown[]) => { + output += args.join(" "); + }; + try { + printUsage(); + } finally { + console.log = originalLog; + } + + assert.match(output, /Sync \/ update options:/); + assert.match(output, /ntn-roblox update/); + assert.match(output, /create-db/); + const syncSection = output.slice(output.indexOf("Sync / update options:")); + assert.doesNotMatch(syncSection, /--force/); + }); + + it("parses update command and --target", () => { + const parsed = parseArgs(["update", "--target=main", "--dry-run"]); + assert.equal(parsed.command, "update"); + if (parsed.command === "update" || parsed.command === "sync") { + assert.equal(parsed.targetFilter, "main"); + assert.equal(parsed.dryRun, true); + } + }); + + it("parses create-db alias", () => { + const parsed = parseArgs(["create-db", "--force"]); + assert.equal(parsed.command, "create-databases"); + }); + + it("exports VERSION from package.json", () => { + assert.match(VERSION, /^0\.2\.0$/); + }); +}); diff --git a/test/config.test.ts b/test/config.test.ts index ba39c90..e0d6efc 100644 --- a/test/config.test.ts +++ b/test/config.test.ts @@ -106,7 +106,7 @@ level = "info" env: { NOTION_TOKEN: "create-db-token" }, tomlPath, }), - /parent page ID is required for create-databases/, + /parent page ID is required for create-db/, ); }); @@ -129,22 +129,23 @@ parent_page_id = "parent" }); describe("loadSyncConfig", () => { - it("loads when NOTION_TOKEN, ROBLOX_API_KEY, and database IDs are present", () => { - const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-")); - const tomlPath = writeToml( - dir, - `[notion] + const syncTomlBase = `[notion] dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" badge_db_id = "cccccccccccccccccccccccccccccccc" +asset_db_id = "dddddddddddddddddddddddddddddddd" [roblox] universe_id = 987654321 +asset_creator = { is_group = false, id = 87654321 } [logging] level = "warn" -`, - ); +`; + + it("loads when NOTION_TOKEN, ROBLOX_API_KEY, and database IDs are present", () => { + const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-")); + const tomlPath = writeToml(dir, syncTomlBase); const config = loadSyncConfig({ env: { @@ -160,7 +161,16 @@ level = "warn" config.NOTION_DEVPRODUCT_DB_ID, "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", ); + assert.equal( + config.NOTION_ASSET_DB_ID, + "dddddddddddddddddddddddddddddddd", + ); assert.equal(config.ROBLOX_UNIVERSE_ID, 987654321); + assert.equal(config.NOTION_IS_INLINE, true); + assert.deepEqual(config.ROBLOX_ASSET_CREATOR, { + is_group: false, + id: 87654321, + }); assert.equal(config.LOG_LEVEL, "warn"); }); @@ -168,14 +178,7 @@ level = "warn" const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-")); const tomlPath = writeToml( dir, - `[notion] -dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -badge_db_id = "cccccccccccccccccccccccccccccccc" - -[roblox] -universe_id = 123 -`, + syncTomlBase.replace("[logging]\nlevel = \"warn\"\n", ""), ); assert.throws( @@ -189,26 +192,39 @@ universe_id = 123 }); it("throws when NOTION_TOKEN is missing", () => { + const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-")); + const tomlPath = writeToml(dir, syncTomlBase); + + assert.throws( + () => + loadSyncConfig({ + env: { ROBLOX_API_KEY: "sync-roblox" }, + tomlPath, + }), + /NOTION_TOKEN.*Required/, + ); + }); + + it("throws when asset_db_id is configured without asset_creator", () => { const dir = mkdtempSync(join(tmpdir(), "ntn-sync-config-")); const tomlPath = writeToml( dir, - `[notion] -dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" -game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" -badge_db_id = "cccccccccccccccccccccccccccccccc" - -[roblox] -universe_id = 123 -`, + syncTomlBase.replace( + "asset_creator = { is_group = false, id = 87654321 }\n", + "", + ), ); assert.throws( () => loadSyncConfig({ - env: { ROBLOX_API_KEY: "sync-roblox" }, + env: { + NOTION_TOKEN: "sync-notion", + ROBLOX_API_KEY: "sync-roblox", + }, tomlPath, }), - /NOTION_TOKEN.*Required/, + /asset_creator.*required/i, ); }); @@ -221,9 +237,11 @@ parent_page_id = "" dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" badge_db_id = "cccccccccccccccccccccccccccccccc" +asset_db_id = "dddddddddddddddddddddddddddddddd" [roblox] universe_id = 123 +asset_creator = { is_group = false, id = 87654321 } `, ); diff --git a/test/createDatabases.test.ts b/test/createDatabases.test.ts index f6a0b4f..03abbb3 100644 --- a/test/createDatabases.test.ts +++ b/test/createDatabases.test.ts @@ -10,12 +10,14 @@ const CREATED_IDS = { devProductDbId: "11111111111111111111111111111111", gamePassDbId: "22222222222222222222222222222222", badgeDbId: "33333333333333333333333333333333", + assetDbId: "44444444444444444444444444444444", }; const DB_CREATE_ORDER = [ CREATED_IDS.devProductDbId, CREATED_IDS.gamePassDbId, CREATED_IDS.badgeDbId, + CREATED_IDS.assetDbId, ]; function makeConfig(overrides: Partial = {}): Config { @@ -25,8 +27,11 @@ function makeConfig(overrides: Partial = {}): Config { NOTION_DEVPRODUCT_DB_ID: "", NOTION_GAMEPASS_DB_ID: "", NOTION_BADGE_DB_ID: "", + NOTION_ASSET_DB_ID: "", NOTION_PARENT_PAGE_ID: "parent-page-id-1234567890123456", + NOTION_IS_INLINE: true, ROBLOX_UNIVERSE_ID: 0, + ROBLOX_UNIVERSES: {}, ROBLOX_BADGE_PAYMENT_SOURCE: "user", LOG_LEVEL: "info", ...overrides, @@ -101,7 +106,7 @@ describe("runCreateDatabases", () => { assert.deepEqual(result, CREATED_IDS); assert.equal(pagesRetrieve.mock.callCount(), 1); - assert.equal(databasesCreate.mock.callCount(), 3); + assert.equal(databasesCreate.mock.callCount(), 4); }); it("throws when database IDs are already configured", async () => { @@ -134,7 +139,7 @@ describe("runCreateDatabases", () => { ); assert.deepEqual(result, CREATED_IDS); - assert.equal(databasesCreate.mock.callCount(), 3); + assert.equal(databasesCreate.mock.callCount(), 4); }); it("writes created database IDs to TOML by default", async () => { @@ -151,6 +156,7 @@ describe("runCreateDatabases", () => { dev_product_db_id: string; game_pass_db_id: string; badge_db_id: string; + asset_db_id: string; parent_page_id: string; }; }; @@ -158,6 +164,7 @@ describe("runCreateDatabases", () => { assert.equal(parsed.notion.dev_product_db_id, CREATED_IDS.devProductDbId); assert.equal(parsed.notion.game_pass_db_id, CREATED_IDS.gamePassDbId); assert.equal(parsed.notion.badge_db_id, CREATED_IDS.badgeDbId); + assert.equal(parsed.notion.asset_db_id, CREATED_IDS.assetDbId); assert.equal( parsed.notion.parent_page_id, "parent-page-id-1234567890123456", diff --git a/test/engine.test.ts b/test/engine.test.ts index b581787..4c58ae3 100644 --- a/test/engine.test.ts +++ b/test/engine.test.ts @@ -24,11 +24,18 @@ describe("badge quota consumption", () => { }), writebackError: mock.fn(async () => {}), writebackSkipped: mock.fn(async () => {}), + writebackAggregatedTargetResults: mock.fn(async () => {}), + aggregateTargetResults: mock.fn(() => ({ + syncStatus: "Synced", + syncError: "", + robloxIds: {}, + })), }, }); mock.module("../src/util/download.js", { namedExports: { downloadFile: mock.fn(async () => undefined), + MAX_ASSET_DOWNLOAD_BYTES: 20 * 1024 * 1024, }, }); @@ -49,6 +56,7 @@ describe("badge quota consumption", () => { const result = await processCandidate(candidate, { dryRun: false, badgeQuota, + targetKey: null, }); assert.equal(result.outcome, "error"); diff --git a/test/engineAsset.test.ts b/test/engineAsset.test.ts new file mode 100644 index 0000000..ca55dce --- /dev/null +++ b/test/engineAsset.test.ts @@ -0,0 +1,99 @@ +import assert from "node:assert/strict"; +import { after, before, beforeEach, describe, it, mock } from "node:test"; +import { makeRow } from "./helpers.js"; +import "./setup.js"; + +describe("asset sync", () => { + let processCandidate: typeof import("../src/sync/engine.js").processCandidate; + const createAsset = mock.fn(async () => 99_001); + const updateAssetMetadata = mock.fn(async () => {}); + const downloadFile = mock.fn(async () => ({ + buffer: Buffer.from("file"), + filename: "model.fbx", + mimeType: "model/fbx", + })); + + before(async () => { + mock.module("../src/roblox/assets.js", { + namedExports: { + createAsset, + updateAssetMetadata, + }, + }); + mock.module("../src/notion/writeback.js", { + namedExports: { + writebackRobloxId: mock.fn(async () => {}), + writebackSuccess: mock.fn(async () => {}), + writebackError: mock.fn(async () => {}), + writebackSkipped: mock.fn(async () => {}), + writebackAggregatedTargetResults: mock.fn(async () => {}), + aggregateTargetResults: mock.fn(() => ({ + syncStatus: "Synced", + syncError: "", + robloxIds: {}, + })), + }, + }); + mock.module("../src/util/download.js", { + namedExports: { + downloadFile, + MAX_ASSET_DOWNLOAD_BYTES: 20 * 1024 * 1024, + }, + }); + + ({ processCandidate } = await import("../src/sync/engine.js")); + }); + + after(() => { + mock.reset(); + }); + + beforeEach(() => { + createAsset.mock.resetCalls(); + updateAssetMetadata.mock.resetCalls(); + downloadFile.mock.resetCalls(); + }); + + it("creates asset by downloading file and calling createAsset", async () => { + const candidate = { + row: makeRow({ + type: "asset", + assetType: "Model", + fileUrl: "https://example.com/model.fbx", + robloxId: null, + }), + action: "create" as const, + }; + + const result = await processCandidate(candidate, { + dryRun: false, + targetKey: null, + }); + + assert.equal(result.outcome, "created"); + assert.equal(createAsset.mock.callCount(), 1); + assert.equal(downloadFile.mock.callCount(), 1); + }); + + it("updates asset metadata only on update", async () => { + const candidate = { + row: makeRow({ + type: "asset", + assetType: "Image", + fileUrl: "https://example.com/image.png", + robloxId: 55_001, + }), + action: "update" as const, + }; + + const result = await processCandidate(candidate, { + dryRun: false, + targetKey: null, + }); + + assert.equal(result.outcome, "updated"); + assert.equal(updateAssetMetadata.mock.callCount(), 1); + assert.equal(createAsset.mock.callCount(), 0); + assert.equal(downloadFile.mock.callCount(), 0); + }); +}); diff --git a/test/fixtures/ntn-roblox.toml b/test/fixtures/ntn-roblox.toml index 6039605..363502d 100644 --- a/test/fixtures/ntn-roblox.toml +++ b/test/fixtures/ntn-roblox.toml @@ -2,9 +2,11 @@ dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" badge_db_id = "cccccccccccccccccccccccccccccccc" +asset_db_id = "dddddddddddddddddddddddddddddddd" [roblox] -universe_id = 1234567890 +universe_id = 123456789 +asset_creator = { is_group = false, id = 87654321 } [logging] level = "info" diff --git a/test/helpers.ts b/test/helpers.ts index 8e75671..ab6dd8f 100644 --- a/test/helpers.ts +++ b/test/helpers.ts @@ -1,18 +1,48 @@ -import type { NotionRow } from "../src/types.js"; +import type { AssetRow, NotionRow } from "../src/types.js"; export function makeRow(overrides: Partial = {}): NotionRow { - return { + const base = { pageId: "page-1", name: "Test Item", description: "desc", iconUrl: null, robloxId: null, - syncStatus: "Pending", + syncStatus: "Pending" as const, syncError: "", lastSyncedAt: null, - type: "developer-product", - price: 100, - isForSale: true, ...overrides, }; + + if (base.type === "asset") { + return { + ...base, + type: "asset", + assetType: (base as Partial).assetType ?? "Model", + fileUrl: (base as Partial).fileUrl ?? null, + } as AssetRow; + } + + if (base.type === "badge") { + return { + ...base, + type: "badge", + isActive: (base as { isActive?: boolean }).isActive ?? true, + } as NotionRow; + } + + if (base.type === "game-pass") { + return { + ...base, + type: "game-pass", + price: (base as { price?: number | null }).price ?? 100, + isForSale: (base as { isForSale?: boolean }).isForSale ?? true, + } as NotionRow; + } + + return { + ...base, + type: "developer-product", + price: (base as { price?: number | null }).price ?? 100, + isForSale: (base as { isForSale?: boolean }).isForSale ?? true, + } as NotionRow; } diff --git a/test/mapRow.test.ts b/test/mapRow.test.ts new file mode 100644 index 0000000..1b92f49 --- /dev/null +++ b/test/mapRow.test.ts @@ -0,0 +1,193 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { after, before, describe, it } from "node:test"; +import type { PageObjectResponse } from "@notionhq/client/build/src/api-endpoints.js"; +import { loadSyncConfig, setConfig } from "../src/config.js"; +import { mapAssetPage, mapDeveloperProductPage } from "../src/notion/mapRow.js"; + +function makePage( + properties: PageObjectResponse["properties"], +): PageObjectResponse { + return { + object: "page", + id: "page-test-1", + created_time: "2026-01-01T00:00:00.000Z", + last_edited_time: "2026-01-01T00:00:00.000Z", + archived: false, + in_trash: false, + url: "https://notion.so/page-test-1", + properties, + parent: { type: "database_id", database_id: "db-1" }, + icon: null, + cover: null, + created_by: { object: "user", id: "user-1" }, + last_edited_by: { object: "user", id: "user-1" }, + public_url: null, + } as PageObjectResponse; +} + +function baseProperties( + extras: PageObjectResponse["properties"] = {}, +): PageObjectResponse["properties"] { + return { + Name: { + id: "name", + type: "title", + title: [ + { + type: "text", + plain_text: "Test", + text: { content: "Test", link: null }, + annotations: { + bold: false, + italic: false, + strikethrough: false, + underline: false, + code: false, + color: "default", + }, + href: null, + }, + ], + }, + Description: { id: "desc", type: "rich_text", rich_text: [] }, + "Sync Status": { + id: "status", + type: "select", + select: { id: "pending", name: "Pending", color: "default" }, + }, + "Sync Error": { id: "err", type: "rich_text", rich_text: [] }, + "Last Synced At": { id: "synced", type: "date", date: null }, + ...extras, + }; +} + +describe("mapAssetPage", () => { + it("maps asset fields and validates Asset Type", () => { + const mapped = mapAssetPage( + makePage( + baseProperties({ + "Asset Type": { + id: "type", + type: "select", + select: { id: "model", name: "Model", color: "default" }, + }, + File: { + id: "file", + type: "files", + files: [ + { + name: "model.fbx", + type: "external", + external: { url: "https://example.com/model.fbx" }, + }, + ], + }, + "Roblox ID": { id: "rid", type: "number", number: 12345 }, + }), + ), + ); + + assert.ok(!("message" in mapped)); + if ("message" in mapped) { + return; + } + + assert.equal(mapped.type, "asset"); + assert.equal(mapped.assetType, "Model"); + assert.equal(mapped.fileUrl, "https://example.com/model.fbx"); + assert.equal(mapped.robloxId, 12345); + }); + + it("returns mapping error for unsupported Asset Type", () => { + const mapped = mapAssetPage( + makePage( + baseProperties({ + "Asset Type": { + id: "type", + type: "select", + select: { id: "mesh", name: "MeshPart", color: "default" }, + }, + File: { id: "file", type: "files", files: [] }, + }), + ), + ); + + assert.ok("message" in mapped); + if ("message" in mapped) { + assert.match(mapped.message, /Invalid Asset Type/); + } + }); +}); + +describe("mapDeveloperProductPage multi-universe", () => { + let multiTomlPath: string; + + before(() => { + const dir = mkdtempSync(join(tmpdir(), "ntn-maprow-")); + multiTomlPath = join(dir, "ntn-roblox.toml"); + writeFileSync( + multiTomlPath, + `[notion] +dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" +game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" +badge_db_id = "cccccccccccccccccccccccccccccccc" +asset_db_id = "dddddddddddddddddddddddddddddddd" + +[roblox] +universes = { main = 111, staging = 222 } +asset_creator = { is_group = false, id = 87654321 } +`, + "utf8", + ); + + setConfig( + loadSyncConfig({ + env: { + NOTION_TOKEN: "test-notion-token", + ROBLOX_API_KEY: "test-roblox-api-key", + }, + tomlPath: multiTomlPath, + }), + ); + }); + + after(() => { + setConfig( + loadSyncConfig({ + env: { + NOTION_TOKEN: "test-notion-token", + ROBLOX_API_KEY: "test-roblox-api-key", + }, + }), + ); + }); + + it("reads per-key Roblox ID columns with robloxId null", () => { + const mapped = mapDeveloperProductPage( + makePage( + baseProperties({ + "Roblox ID (main)": { id: "rid-main", type: "number", number: 111 }, + "Roblox ID (staging)": { + id: "rid-staging", + type: "number", + number: null, + }, + Price: { id: "price", type: "number", number: 50 }, + "Is For Sale": { id: "sale", type: "checkbox", checkbox: true }, + Icon: { id: "icon", type: "files", files: [] }, + }), + ), + ); + + assert.ok(!("message" in mapped)); + if ("message" in mapped) { + return; + } + + assert.equal(mapped.robloxId, null); + assert.deepEqual(mapped.robloxIds, { main: 111, staging: null }); + }); +}); diff --git a/test/propertyNames.test.ts b/test/propertyNames.test.ts new file mode 100644 index 0000000..38c30d8 --- /dev/null +++ b/test/propertyNames.test.ts @@ -0,0 +1,47 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; +import { robloxIdPropertyName } from "../src/notion/propertyNames.js"; +import { aggregateTargetResults } from "../src/notion/writeback.js"; + +describe("robloxIdPropertyName", () => { + it("returns Roblox ID for single-universe mode", () => { + assert.equal(robloxIdPropertyName(null), "Roblox ID"); + }); + + it("returns per-key column name in multi-universe mode", () => { + assert.equal(robloxIdPropertyName("main"), "Roblox ID (main)"); + assert.equal(robloxIdPropertyName("staging"), "Roblox ID (staging)"); + }); +}); + +describe("aggregateTargetResults", () => { + it("prefixes errors with target key in multi-universe mode", () => { + const aggregated = aggregateTargetResults([ + { + targetKey: "main", + outcome: "error", + message: "API failed", + }, + { + targetKey: "staging", + outcome: "created", + robloxId: 100, + }, + ]); + + assert.equal(aggregated.syncStatus, "Error"); + assert.match(aggregated.syncError, /\[main\] API failed/); + assert.equal(aggregated.robloxIds.staging, 100); + }); + + it("returns Synced when all targets succeed", () => { + const aggregated = aggregateTargetResults([ + { targetKey: "main", outcome: "updated", robloxId: 1 }, + { targetKey: "staging", outcome: "created", robloxId: 2 }, + ]); + + assert.equal(aggregated.syncStatus, "Synced"); + assert.equal(aggregated.syncError, ""); + assert.deepEqual(aggregated.robloxIds, { main: 1, staging: 2 }); + }); +}); diff --git a/test/tomlWrite.test.ts b/test/tomlWrite.test.ts index 6cb485b..30e5106 100644 --- a/test/tomlWrite.test.ts +++ b/test/tomlWrite.test.ts @@ -7,6 +7,7 @@ const IDS = { devProductDbId: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", gamePassDbId: "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb", badgeDbId: "cccccccccccccccccccccccccccccccc", + assetDbId: "dddddddddddddddddddddddddddddddd", }; describe("updateNotionDatabaseIdsInToml", () => { @@ -38,6 +39,7 @@ universe_id = 123 dev_product_db_id: string; game_pass_db_id: string; badge_db_id: string; + asset_db_id: string; }; }; assert.equal(parsed.notion.dev_product_db_id, IDS.devProductDbId); @@ -57,7 +59,7 @@ universe_id = 123 assert.match( output, - /\[notion\]\ndev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ngame_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nbadge_db_id = "cccccccccccccccccccccccccccccccc"\nparent_page_id = "parent-should-stay"/, + /\[notion\]\ndev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"\ngame_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"\nbadge_db_id = "cccccccccccccccccccccccccccccccc"\nasset_db_id = "dddddddddddddddddddddddddddddddd"\nparent_page_id = "parent-should-stay"/, ); }); @@ -85,8 +87,7 @@ parent_page_id = "parent-should-stay" assert.match(output, /dev_product_db_id = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"/); assert.match(output, /game_pass_db_id = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"/); - assert.match(output, /badge_db_id = "cccccccccccccccccccccccccccccccc"/); - assert.match(output, /parent_page_id = "parent-should-stay"/); + assert.match(output, /asset_db_id = "dddddddddddddddddddddddddddddddd"/); assert.doesNotMatch(output, /old-dev/); });