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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/icon-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
"thatopen-services": minor
---

Add icon support for items (apps, components, files).

**Library:** New `uploadItemIcon`, `getItemIcon`, and `removeItemIcon` methods on `EngineServicesClient` for managing item icons via the `PUT/GET/DELETE /api/item/:id/icon` endpoints. Accepts PNG, WebP, and ICO images up to 512 KB.

**CLI:** `thatopen publish --icon <path>` uploads an icon after publishing. The icon path is saved to `.thatopen` config so subsequent publishes reuse it automatically.
4 changes: 0 additions & 4 deletions .eslintignore

This file was deleted.

8 changes: 0 additions & 8 deletions eslint.config.js

This file was deleted.

14 changes: 14 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import globals from "globals";
import tseslint from "typescript-eslint";


export default [
{ ignores: ["coverage/", "public/", "dist/"] },
{ languageOptions: { globals: globals.browser } },
...tseslint.configs.recommended,
{
rules: {
"@typescript-eslint/no-explicit-any": "error",
},
},
];
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@
],
"scripts": {
"dev": "vite",
"build": "tsc && vite build && vite build --config vite.config.cli.mts",
"lint": "eslint src/",
"build": "eslint src/ && tsc && vite build && vite build --config vite.config.cli.mts",
"build:lib": "tsc && vite build",
"build:cli": "vite build --config vite.config.cli.mts",
"preview": "vite preview",
Expand Down
73 changes: 67 additions & 6 deletions src/cli/commands/publish.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Command } from 'commander';
import { existsSync, readFileSync } from 'node:fs';
import { join, basename } from 'node:path';
import { existsSync, readFileSync, statSync } from 'node:fs';
import { join, basename, extname } from 'node:path';
import { execSync } from 'node:child_process';
import {
requireResolvedConfig,
Expand All @@ -23,13 +23,15 @@ export const publishCommand = new Command('publish')
'Existing component ID to publish a new version for',
)
.option('--skip-build', 'Skip the build step')
.option('--icon <path>', 'Path to an icon file (PNG, WebP, or ICO, max 512 KB)')
.action(
async (opts: {
name?: string;
versionTag?: string;
appId?: string;
componentId?: string;
skipBuild?: boolean;
icon?: string;
}) => {
const cwd = process.cwd();
const config = requireResolvedConfig(cwd);
Expand Down Expand Up @@ -89,15 +91,20 @@ export const publishCommand = new Command('publish')
const zipBuffer = readFileSync(zipPath);
const zipBlob = new Blob([zipBuffer]);

// Resolve icon: CLI flag > local config
const iconPath = opts.icon || localConfig?.iconPath;

// Upload
const client = new EngineServicesClient(
config.accessToken,
config.apiUrl,
);

try {
let itemId: string | undefined;

if (isComponent) {
await publishComponent(
itemId = await publishComponent(
client,
existingId,
zipBlob,
Expand All @@ -106,7 +113,7 @@ export const publishCommand = new Command('publish')
cwd,
);
} else {
await publishApp(
itemId = await publishApp(
client,
existingId,
zipBlob,
Expand All @@ -116,6 +123,11 @@ export const publishCommand = new Command('publish')
);
}

// Upload icon if specified
if (iconPath && itemId) {
await uploadIcon(client, itemId, iconPath, opts.icon, cwd);
}

console.log('Published successfully!');
} catch (err) {
const message = (err as Error).message || String(err);
Expand Down Expand Up @@ -150,7 +162,7 @@ async function publishApp(
name: string,
versionTag: string,
cwd: string,
) {
): Promise<string | undefined> {
if (appId) {
// Auto-recover if the app was archived (deleted from UI)
const existing = await client.getFile(appId);
Expand All @@ -169,6 +181,7 @@ async function publishApp(
{}, // extraProps required by backend for APP items
);
console.log('Version created:', JSON.stringify(result, null, 2));
return appId;
} else {
console.log(`Publishing new app "${name}" (${versionTag})...`);
const result = await client.createApp({
Expand All @@ -184,6 +197,7 @@ async function publishApp(
updateLocalConfig({ appId: String(newAppId) }, cwd);
console.log(`App ID saved to .thatopen (${newAppId})`);
}
return newAppId ? String(newAppId) : undefined;
}
}

Expand All @@ -198,7 +212,7 @@ async function publishComponent(
name: string,
versionTag: string,
cwd: string,
) {
): Promise<string | undefined> {
const componentProps = {
type: 'CLOUD' as const,
tier: 'FREE' as const,
Expand All @@ -222,6 +236,7 @@ async function publishComponent(
componentProps,
});
console.log('Version created:', JSON.stringify(result, null, 2));
return componentId;
} else {
console.log(
`Publishing new cloud component "${name}" (${versionTag})...`,
Expand All @@ -240,5 +255,51 @@ async function publishComponent(
updateLocalConfig({ componentId: String(newId) }, cwd);
console.log(`Component ID saved to .thatopen (${newId})`);
}
return newId ? String(newId) : undefined;
}
}

// ---------------------------------------------------------------------------
// Icon upload helper
// ---------------------------------------------------------------------------

const ALLOWED_ICON_EXTENSIONS = ['.png', '.webp', '.ico'];
const MAX_ICON_SIZE = 512 * 1024; // 512 KB

async function uploadIcon(
client: EngineServicesClient,
itemId: string,
iconPath: string,
cliIconFlag: string | undefined,
cwd: string,
) {
const resolvedPath = join(cwd, iconPath);

if (!existsSync(resolvedPath)) {
console.error(`Icon file not found: ${resolvedPath}`);
return;
}

const ext = extname(resolvedPath).toLowerCase();
if (!ALLOWED_ICON_EXTENSIONS.includes(ext)) {
console.error(`Unsupported icon format "${ext}". Use PNG, WebP, or ICO.`);
return;
}

const size = statSync(resolvedPath).size;
if (size > MAX_ICON_SIZE) {
console.error(`Icon too large (${Math.round(size / 1024)} KB). Maximum is 512 KB.`);
return;
}

console.log('Uploading icon...');
const iconBuffer = readFileSync(resolvedPath);
const iconBlob = new Blob([iconBuffer]);
await client.uploadItemIcon(itemId, iconBlob);
console.log('Icon uploaded.');

// Save icon path to local config if provided via CLI flag
if (cliIconFlag) {
updateLocalConfig({ iconPath: iconPath }, cwd);
}
}
2 changes: 2 additions & 0 deletions src/cli/lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ export interface ThatOpenLocalConfig {
componentId?: string;
/** Project type marker set by `thatopen create`. */
itemType?: ProjectType;
/** Path to an icon file (PNG, WebP, or ICO) uploaded on publish. */
iconPath?: string;
}

const LOCAL_CONFIG_FILE = '.thatopen';
Expand Down
71 changes: 63 additions & 8 deletions src/core/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,13 @@ import { CreateHiddenItemResult, HiddenFileEntity } from '../types/files';
import { Project, ProjectData } from '../types/projects';
import { ThatOpenContext } from '../types/context';

declare global {
interface Window {
__THATOPEN_CONTEXT__?: ThatOpenContext;
ThatOpenCompany?: Record<string, unknown>;
}
}

const FOLDER_PATH = 'item/folder';
const ITEM_PATH = 'item';
const PROCESS_PATH = 'processor';
Expand All @@ -29,6 +36,15 @@ const ITEM_TYPE_FILE = 'FILE';
const ITEM_TYPE_COMPONENT = 'TOOL';
const ITEM_TYPE_APP = 'APP';

/**
* Minimal shape of an OBC.Components-like object.
* Avoids hard-coupling to `@thatopen/components` at the public API level.
*/
export interface ComponentsLike {
get(c: new (components: ComponentsLike) => unknown): unknown;
init(): void;
}

/** Properties for creating a new item (file, component, or app). */
export type CreateItemProps = {
/** The file to upload (File in browsers, Blob in Node.js). */
Expand Down Expand Up @@ -180,7 +196,7 @@ export class EngineServicesClient {
): EngineServicesClient {
const ctx: ThatOpenContext =
(typeof window !== 'undefined'
? (window as any).__THATOPEN_CONTEXT__
? window.__THATOPEN_CONTEXT__
: null) || { appId: '', projectId: '', accessToken: '', apiUrl: '' };
const client = new EngineServicesClient(ctx.accessToken, ctx.apiUrl, {
...props,
Expand Down Expand Up @@ -710,20 +726,19 @@ export class EngineServicesClient {
*/
async initBuiltInComponent(
component: { uuid: string },
components: { get: (c: new (components: any) => any) => any },
components: ComponentsLike,
globals?: Record<string, unknown>,
): Promise<void> {
const source = await this.getBuiltInComponent(component.uuid);
const resolvedGlobals =
globals ??
this.builtInGlobals ??
(typeof window !== 'undefined' ? (window as any).ThatOpenCompany : {}) ??
(typeof window !== 'undefined' ? window.ThatOpenCompany : {}) ??
{};

const keys = Object.keys(resolvedGlobals);
const values = keys.map((k) => resolvedGlobals[k]);

// eslint-disable-next-line no-new-func
const factory = new Function(...keys, `${source}\nreturn main;`);
const main = factory(...values);

Expand All @@ -746,7 +761,7 @@ export class EngineServicesClient {
* ```
*/
async initBuiltInComponents(
components: { get: (c: new (components: any) => any) => any },
components: ComponentsLike,
...stubs: { uuid: string }[]
): Promise<void> {
await Promise.all(
Expand Down Expand Up @@ -777,9 +792,9 @@ export class EngineServicesClient {
async initApp(
globals: Record<string, unknown>,
...builtIns: { uuid: string }[]
): Promise<{ components: any }> {
const OBC = globals.OBC as any;
const BUI = globals.BUI as any;
): Promise<{ components: ComponentsLike }> {
const OBC = globals.OBC as { Components?: new () => ComponentsLike } | undefined;
const BUI = globals.BUI as { Manager?: { init(): void } } | undefined;
if (!OBC?.Components)
throw new Error('globals.OBC must include Components');
if (!BUI?.Manager) throw new Error('globals.BUI must include Manager');
Expand Down Expand Up @@ -1079,6 +1094,46 @@ export class EngineServicesClient {
);
}

// ─── Icons ───────────────────────────────────────────────────────

/**
* Uploads or replaces the icon for an item (app, component, or file).
* Accepts PNG, WebP, or ICO images up to 512 KB.
* @param itemId - The item's unique identifier.
* @param icon - The icon image file (File in browsers, Blob in Node.js).
* @returns The updated item with `iconFileId` and `iconMimeType` set.
*/
async uploadItemIcon(itemId: string, icon: File | Blob) {
const formData = new FormData();
formData.append('icon', icon);
return await this.#requestApi<Item>(
'PUT',
`${ITEM_PATH}/${itemId}/icon`,
{ body: formData },
);
}

/**
* Downloads the icon for an item as a binary stream.
* @param itemId - The item's unique identifier.
* @returns The raw Response (use `.blob()`, `.arrayBuffer()`, or pipe the body).
*/
async getItemIcon(itemId: string) {
return await this.#requestFile(`${ITEM_PATH}/${itemId}/icon`);
}

/**
* Removes the icon from an item.
* @param itemId - The item's unique identifier.
* @returns The updated item with icon fields removed.
*/
async removeItemIcon(itemId: string) {
return await this.#requestApi<Item>(
'DELETE',
`${ITEM_PATH}/${itemId}/icon`,
);
}

// ─── General Item Operations ─────────────────────────────────────

/**
Expand Down
2 changes: 1 addition & 1 deletion src/types/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,4 @@ export interface Base {
archived?: boolean;
}

export type ObjectId = string | any;
export type ObjectId = string;