feat(core): implement Kintone Customize Manager core features
Wave 1 - Foundation: - Add shared TypeScript type definitions (domain, kintone, version, ipc) - Implement storage module with safeStorage encryption - Implement Kintone REST API client with authentication - Add IPC handlers for all features - Expose APIs via preload contextBridge - Setup Zustand stores for state management Wave 2 - Domain Management: - Implement Domain store with IPC actions - Create DomainManager, DomainList, DomainForm components - Connect UI components to store Wave 3 - Resource Browsing: - Create SpaceTree component for browsing spaces/apps - Create AppDetail component for app configuration view - Create CodeViewer component with syntax highlighting Wave 4 - Deployment: - Create FileUploader drag-and-drop component - Create DeployDialog with step-by-step deployment flow - Implement deployment IPC handler with auto-backup Wave 5 - Version Management: - Create VersionHistory component - Implement version storage and rollback logic Wave 6 - Integration: - Integrate all components into main App layout - Update main process entry point - Configure TypeScript paths for renderer imports
This commit is contained in:
508
src/main/kintone-api.ts
Normal file
508
src/main/kintone-api.ts
Normal file
@@ -0,0 +1,508 @@
|
||||
/**
|
||||
* Kintone REST API Client
|
||||
* Handles authentication and API calls to Kintone
|
||||
* Based on REQUIREMENTS.md:331-345, 502-522
|
||||
*/
|
||||
|
||||
import type { DomainWithPassword } from "@renderer/types/domain";
|
||||
import type {
|
||||
KintoneSpace,
|
||||
KintoneApp,
|
||||
AppDetail,
|
||||
FileContent,
|
||||
AppCustomizationConfig,
|
||||
KintoneApiError,
|
||||
} from "@renderer/types/kintone";
|
||||
|
||||
const REQUEST_TIMEOUT = 30000; // 30 seconds
|
||||
|
||||
/**
|
||||
* Custom error class for Kintone API errors
|
||||
*/
|
||||
export class KintoneError extends Error {
|
||||
public readonly code?: string;
|
||||
public readonly id?: string;
|
||||
public readonly statusCode?: number;
|
||||
|
||||
constructor(
|
||||
message: string,
|
||||
apiError?: KintoneApiError,
|
||||
statusCode?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = "KintoneError";
|
||||
this.code = apiError?.code;
|
||||
this.id = apiError?.id;
|
||||
this.statusCode = statusCode;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kintone REST API Client
|
||||
*/
|
||||
export class KintoneClient {
|
||||
private baseUrl: string;
|
||||
private headers: Headers;
|
||||
private domain: string;
|
||||
|
||||
constructor(domainConfig: DomainWithPassword) {
|
||||
this.domain = domainConfig.domain;
|
||||
this.baseUrl = `https://${domainConfig.domain}/k/v1/`;
|
||||
this.headers = new Headers({
|
||||
"Content-Type": "application/json",
|
||||
});
|
||||
|
||||
if (domainConfig.authType === "api_token") {
|
||||
// API Token authentication
|
||||
this.headers.set("X-Cybozu-API-Token", domainConfig.apiToken || "");
|
||||
} else {
|
||||
// Password authentication (Basic Auth)
|
||||
const credentials = Buffer.from(
|
||||
`${domainConfig.username}:${domainConfig.password}`,
|
||||
).toString("base64");
|
||||
this.headers.set("X-Cybozu-Authorization", credentials);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an API request with timeout
|
||||
*/
|
||||
private async request<T>(
|
||||
endpoint: string,
|
||||
options: RequestInit = {},
|
||||
): Promise<T> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||
|
||||
try {
|
||||
const url = this.baseUrl + endpoint;
|
||||
const response = await fetch(url, {
|
||||
...options,
|
||||
headers: this.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!response.ok) {
|
||||
const apiError = data as KintoneApiError;
|
||||
throw new KintoneError(
|
||||
apiError.message || `API request failed: ${response.status}`,
|
||||
apiError,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return data as T;
|
||||
} catch (error) {
|
||||
if (error instanceof KintoneError) {
|
||||
throw error;
|
||||
}
|
||||
if (error instanceof Error) {
|
||||
if (error.name === "AbortError") {
|
||||
throw new KintoneError("Request timeout");
|
||||
}
|
||||
throw new KintoneError(`Network error: ${error.message}`);
|
||||
}
|
||||
throw new KintoneError("Unknown error occurred");
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Space APIs ====================
|
||||
|
||||
/**
|
||||
* Get list of spaces
|
||||
* GET /k/v1/space.json
|
||||
*/
|
||||
async getSpaces(): Promise<KintoneSpace[]> {
|
||||
interface SpacesResponse {
|
||||
spaces: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
code: string;
|
||||
createdAt?: string;
|
||||
creator?: { code: string; name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
const response = await this.request<SpacesResponse>("space.json");
|
||||
|
||||
return response.spaces.map((space) => ({
|
||||
id: space.id,
|
||||
name: space.name,
|
||||
code: space.code,
|
||||
createdAt: space.createdAt,
|
||||
creator: space.creator,
|
||||
}));
|
||||
}
|
||||
|
||||
// ==================== App APIs ====================
|
||||
|
||||
/**
|
||||
* Get list of apps, optionally filtered by space
|
||||
* GET /k/v1/apps.json?space={spaceId}
|
||||
*/
|
||||
async getApps(spaceId?: string): Promise<KintoneApp[]> {
|
||||
interface AppsResponse {
|
||||
apps: Array<{
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
spaceId?: string;
|
||||
createdAt: string;
|
||||
creator?: { code: string; name: string };
|
||||
modifiedAt?: string;
|
||||
modifier?: { code: string; name: string };
|
||||
}>;
|
||||
}
|
||||
|
||||
let endpoint = "apps.json";
|
||||
if (spaceId) {
|
||||
endpoint += `?space=${spaceId}`;
|
||||
}
|
||||
|
||||
const response = await this.request<AppsResponse>(endpoint);
|
||||
|
||||
return response.apps.map((app) => ({
|
||||
appId: app.appId,
|
||||
name: app.name,
|
||||
code: app.code,
|
||||
spaceId: app.spaceId,
|
||||
createdAt: app.createdAt,
|
||||
creator: app.creator,
|
||||
modifiedAt: app.modifiedAt,
|
||||
modifier: app.modifier,
|
||||
}));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get app details including customization config
|
||||
* GET /k/v1/app.json?app={appId}
|
||||
*/
|
||||
async getAppDetail(appId: string): Promise<AppDetail> {
|
||||
interface AppResponse {
|
||||
appId: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
description?: string;
|
||||
spaceId?: string;
|
||||
createdAt: string;
|
||||
creator: { code: string; name: string };
|
||||
modifiedAt: string;
|
||||
modifier: { code: string; name: string };
|
||||
}
|
||||
|
||||
interface AppCustomizeResponse {
|
||||
appId: string;
|
||||
scope: string;
|
||||
desktop: {
|
||||
js?: Array<{
|
||||
type: string;
|
||||
file?: { fileKey: string; name: string };
|
||||
url?: string;
|
||||
}>;
|
||||
css?: Array<{
|
||||
type: string;
|
||||
file?: { fileKey: string; name: string };
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
mobile: {
|
||||
js?: Array<{
|
||||
type: string;
|
||||
file?: { fileKey: string; name: string };
|
||||
url?: string;
|
||||
}>;
|
||||
css?: Array<{
|
||||
type: string;
|
||||
file?: { fileKey: string; name: string };
|
||||
url?: string;
|
||||
}>;
|
||||
};
|
||||
}
|
||||
|
||||
// Get basic app info
|
||||
const appInfo = await this.request<AppResponse>(`app.json?app=${appId}`);
|
||||
|
||||
// Get customization config
|
||||
const customizeInfo = await this.request<AppCustomizeResponse>(
|
||||
`app/customize.json?app=${appId}`,
|
||||
);
|
||||
|
||||
// Transform customization config
|
||||
const customization: AppCustomizationConfig = {
|
||||
javascript: {
|
||||
pc:
|
||||
customizeInfo.desktop?.js?.map((js) => ({
|
||||
type: js.type as "FILE" | "URL",
|
||||
file: js.file
|
||||
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
|
||||
: undefined,
|
||||
url: js.url,
|
||||
})) || [],
|
||||
mobile:
|
||||
customizeInfo.mobile?.js?.map((js) => ({
|
||||
type: js.type as "FILE" | "URL",
|
||||
file: js.file
|
||||
? { fileKey: js.file.fileKey, name: js.file.name, size: 0 }
|
||||
: undefined,
|
||||
url: js.url,
|
||||
})) || [],
|
||||
},
|
||||
stylesheet: {
|
||||
pc:
|
||||
customizeInfo.desktop?.css?.map((css) => ({
|
||||
type: css.type as "FILE" | "URL",
|
||||
file: css.file
|
||||
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
|
||||
: undefined,
|
||||
url: css.url,
|
||||
})) || [],
|
||||
mobile:
|
||||
customizeInfo.mobile?.css?.map((css) => ({
|
||||
type: css.type as "FILE" | "URL",
|
||||
file: css.file
|
||||
? { fileKey: css.file.fileKey, name: css.file.name, size: 0 }
|
||||
: undefined,
|
||||
url: css.url,
|
||||
})) || [],
|
||||
},
|
||||
plugins: [],
|
||||
};
|
||||
|
||||
return {
|
||||
appId: appInfo.appId,
|
||||
name: appInfo.name,
|
||||
code: appInfo.code,
|
||||
description: appInfo.description,
|
||||
spaceId: appInfo.spaceId,
|
||||
createdAt: appInfo.createdAt,
|
||||
creator: appInfo.creator,
|
||||
modifiedAt: appInfo.modifiedAt,
|
||||
modifier: appInfo.modifier,
|
||||
customization,
|
||||
};
|
||||
}
|
||||
|
||||
// ==================== File APIs ====================
|
||||
|
||||
/**
|
||||
* Get file content from Kintone
|
||||
* GET /k/v1/file.json?fileKey={fileKey}
|
||||
*/
|
||||
async getFileContent(fileKey: string): Promise<FileContent> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||
|
||||
try {
|
||||
const url = `${this.baseUrl}file.json?fileKey=${fileKey}`;
|
||||
const response = await fetch(url, {
|
||||
headers: this.headers,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new KintoneError(
|
||||
error.message || "Failed to get file",
|
||||
error,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const contentType =
|
||||
response.headers.get("content-type") || "application/octet-stream";
|
||||
const arrayBuffer = await response.arrayBuffer();
|
||||
const content = Buffer.from(arrayBuffer).toString("base64");
|
||||
|
||||
return {
|
||||
fileKey,
|
||||
name: fileKey, // Kintone doesn't return filename in file API
|
||||
size: arrayBuffer.byteLength,
|
||||
mimeType: contentType,
|
||||
content,
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Upload a file to Kintone
|
||||
* POST /k/v1/file.json (multipart/form-data)
|
||||
*/
|
||||
async uploadFile(
|
||||
content: string | Buffer,
|
||||
fileName: string,
|
||||
mimeType: string = "application/javascript",
|
||||
): Promise<{ fileKey: string }> {
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), REQUEST_TIMEOUT);
|
||||
|
||||
try {
|
||||
const url = `${this.baseUrl}file.json`;
|
||||
|
||||
// Create form data
|
||||
const formData = new FormData();
|
||||
const blob = new Blob(
|
||||
[typeof content === "string" ? content : Buffer.from(content)],
|
||||
{ type: mimeType },
|
||||
);
|
||||
formData.append("file", blob, fileName);
|
||||
|
||||
// Remove Content-Type header to let browser set it with boundary
|
||||
const uploadHeaders = new Headers(this.headers);
|
||||
uploadHeaders.delete("Content-Type");
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: uploadHeaders,
|
||||
body: formData,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const error = await response.json();
|
||||
throw new KintoneError(
|
||||
error.message || "Failed to upload file",
|
||||
error,
|
||||
response.status,
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
return { fileKey: data.fileKey };
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
// ==================== Deploy APIs ====================
|
||||
|
||||
/**
|
||||
* Update app customization config
|
||||
* PUT /k/v1/app/customize.json
|
||||
*/
|
||||
async updateAppCustomize(
|
||||
appId: string,
|
||||
config: AppCustomizationConfig,
|
||||
): Promise<void> {
|
||||
interface CustomizeUpdateRequest {
|
||||
app: string;
|
||||
desktop?: {
|
||||
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
|
||||
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
|
||||
};
|
||||
mobile?: {
|
||||
js?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
|
||||
css?: Array<{ type: string; file?: { fileKey: string }; url?: string }>;
|
||||
};
|
||||
}
|
||||
|
||||
const requestBody: CustomizeUpdateRequest = {
|
||||
app: appId,
|
||||
desktop: {
|
||||
js: config.javascript?.pc
|
||||
?.map((js) => ({
|
||||
type: js.type,
|
||||
file: js.file ? { fileKey: js.file.fileKey } : undefined,
|
||||
url: js.url,
|
||||
}))
|
||||
.filter(Boolean),
|
||||
css: config.stylesheet?.pc
|
||||
?.map((css) => ({
|
||||
type: css.type,
|
||||
file: css.file ? { fileKey: css.file.fileKey } : undefined,
|
||||
url: css.url,
|
||||
}))
|
||||
.filter(Boolean),
|
||||
},
|
||||
mobile: {
|
||||
js: config.javascript?.mobile
|
||||
?.map((js) => ({
|
||||
type: js.type,
|
||||
file: js.file ? { fileKey: js.file.fileKey } : undefined,
|
||||
url: js.url,
|
||||
}))
|
||||
.filter(Boolean),
|
||||
css: config.stylesheet?.mobile
|
||||
?.map((css) => ({
|
||||
type: css.type,
|
||||
file: css.file ? { fileKey: css.file.fileKey } : undefined,
|
||||
url: css.url,
|
||||
}))
|
||||
.filter(Boolean),
|
||||
},
|
||||
};
|
||||
|
||||
await this.request("app/customize.json", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Deploy app changes
|
||||
* POST /k/v1/preview/app/deploy.json
|
||||
*/
|
||||
async deployApp(appId: string): Promise<void> {
|
||||
await this.request("preview/app/deploy.json", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ apps: [{ app: appId }] }),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Get deploy status
|
||||
* GET /k/v1/preview/app/deploy.json?app={appId}
|
||||
*/
|
||||
async getDeployStatus(
|
||||
appId: string,
|
||||
): Promise<"PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL"> {
|
||||
interface DeployStatusResponse {
|
||||
apps: Array<{
|
||||
app: string;
|
||||
status: "PROCESSING" | "SUCCESS" | "FAIL" | "CANCEL";
|
||||
}>;
|
||||
}
|
||||
|
||||
const response = await this.request<DeployStatusResponse>(
|
||||
`preview/app/deploy.json?app=${appId}`,
|
||||
);
|
||||
|
||||
const appStatus = response.apps.find((a) => a.app === appId);
|
||||
return appStatus?.status || "FAIL";
|
||||
}
|
||||
|
||||
// ==================== Utility Methods ====================
|
||||
|
||||
/**
|
||||
* Test connection to Kintone
|
||||
*/
|
||||
async testConnection(): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await this.getApps();
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
error:
|
||||
error instanceof KintoneError ? error.message : "Connection failed",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the domain name
|
||||
*/
|
||||
getDomain(): string {
|
||||
return this.domain;
|
||||
}
|
||||
}
|
||||
|
||||
// Export factory function for convenience
|
||||
export function createKintoneClient(domain: DomainWithPassword): KintoneClient {
|
||||
return new KintoneClient(domain);
|
||||
}
|
||||
Reference in New Issue
Block a user