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:
2026-03-12 11:03:48 +08:00
parent ab7e9b597a
commit da7f566ecf
36 changed files with 5847 additions and 151 deletions

508
src/main/kintone-api.ts Normal file
View 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);
}