snapshot of https://github.com/dream-num/univer.git (dev, 2026-09-16; blobs > 131072B omitted)
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
Some checks failed
📦 Build / build (push) Has been cancelled
🧪 CI Test / test shard (1/4) (push) Has been cancelled
🧪 CI Test / test shard (2/4) (push) Has been cancelled
🧪 CI Test / test shard (3/4) (push) Has been cancelled
🧪 CI Test / test shard (4/4) (push) Has been cancelled
🧪 CI Test / test (push) Has been cancelled
🧪 CI Test / eslint (push) Has been cancelled
🧪 CI Test / typecheck (push) Has been cancelled
🔫 Sync to Pro / dispatch-sync (push) Has been cancelled
🔮 Sync Mirror / sync (push) Has been cancelled
This commit is contained in:
41
packages/action-recorder/README.md
Normal file
41
packages/action-recorder/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# @univerjs/action-recorder
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/action-recorder)
|
||||
[](https://npmjs.com/package/@univerjs/action-recorder)
|
||||
[](https://npmjs.com/package/@univerjs/action-recorder)
|
||||
|
||||
`@univerjs/action-recorder` records user actions in Univer and can replay them for debugging, demos, or automated workflow reproduction.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | UMD global | CSS | Locales | Facade entry |
|
||||
| --- | --- | :---: | :---: | :---: |
|
||||
| `@univerjs/action-recorder` | `UniverActionRecorder` | Yes | Yes | No |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @univerjs/action-recorder
|
||||
# or
|
||||
npm install @univerjs/action-recorder
|
||||
```
|
||||
|
||||
Keep all `@univerjs/*` packages on the same version.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import '@univerjs/action-recorder/lib/index.css';
|
||||
import EnUS from '@univerjs/action-recorder/locale/en-US';
|
||||
import { UniverActionRecorderPlugin } from '@univerjs/action-recorder';
|
||||
|
||||
univer.registerPlugin(UniverActionRecorderPlugin);
|
||||
|
||||
// Merge EnUS into your Univer locale map when this package contributes UI text.
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.univer.ai)
|
||||
- [NPM package](https://npmjs.com/package/@univerjs/action-recorder)
|
||||
- [GitHub repository](https://github.com/dream-num/univer)
|
||||
93
packages/action-recorder/package.json
Normal file
93
packages/action-recorder/package.json
Normal file
@@ -0,0 +1,93 @@
|
||||
{
|
||||
"name": "@univerjs/action-recorder",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Record and replay user actions in Univer.",
|
||||
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/univer"
|
||||
},
|
||||
"homepage": "https://univer.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dream-num/univer"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/dream-num/univer/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"univer",
|
||||
"action-recorder",
|
||||
"replay",
|
||||
"automation",
|
||||
"plugin"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*",
|
||||
"./locale/*": "./src/locale/*.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "./lib/es/index.js",
|
||||
"module": "./lib/es/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./lib/es/index.js",
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./lib/es/*",
|
||||
"require": "./lib/cjs/*",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./locale/*": {
|
||||
"import": "./lib/es/locale/*.js",
|
||||
"require": "./lib/cjs/locale/*.js",
|
||||
"types": "./lib/types/locale/*.d.ts"
|
||||
},
|
||||
"./lib/*": "./lib/*"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:bundle": "univer-cli build",
|
||||
"build:types": "tsc -p tsconfig.node.json",
|
||||
"build": "pnpm run build:bundle && pnpm run build:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^16.9.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc",
|
||||
"rxjs": ">=7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@univerjs/core": "workspace:*",
|
||||
"@univerjs/design": "workspace:*",
|
||||
"@univerjs/icons": "1.38.0",
|
||||
"@univerjs/sheets": "workspace:*",
|
||||
"@univerjs/sheets-filter": "workspace:*",
|
||||
"@univerjs/sheets-ui": "workspace:*",
|
||||
"@univerjs/ui": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"react": "18.3.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"tailwindcss": "3.4.18",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
1
packages/action-recorder/postcss.config.mjs
Normal file
1
packages/action-recorder/postcss.config.mjs
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from '@univerjs-infra/shared/postcss';
|
||||
@@ -0,0 +1,305 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDisposable } from '@univerjs/core';
|
||||
import type { IMessageProps } from '@univerjs/design';
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LogLevel,
|
||||
toDisposable,
|
||||
} from '@univerjs/core';
|
||||
import { MessageType } from '@univerjs/design';
|
||||
import { ILocalFileService, IMessageService } from '@univerjs/ui';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ActionRecorderService } from '../../../services/action-recorder.service';
|
||||
import { ActionReplayService } from '../../../services/replay.service';
|
||||
import {
|
||||
CompleteRecordingActionCommand,
|
||||
StartRecordingActionCommand,
|
||||
StopRecordingActionCommand,
|
||||
} from '../record.command';
|
||||
import {
|
||||
ReplayLocalRecordCommand,
|
||||
ReplayLocalRecordOnActiveCommand,
|
||||
ReplayLocalRecordOnNamesakeCommand,
|
||||
} from '../replay.command';
|
||||
|
||||
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.command-test.apply-cell-value';
|
||||
|
||||
interface ITestFile {
|
||||
name: string;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
interface IAppliedCommandParams {
|
||||
unitId?: string;
|
||||
subUnitId?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
class TestState {
|
||||
static files: ITestFile[] = [];
|
||||
static downloads: Array<{ data: Blob; fileName: string }> = [];
|
||||
static messages: IMessageProps[] = [];
|
||||
static appliedParams: IAppliedCommandParams[] = [];
|
||||
|
||||
static reset() {
|
||||
this.files = [];
|
||||
this.downloads = [];
|
||||
this.messages = [];
|
||||
this.appliedParams = [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestLocalFileService implements ILocalFileService {
|
||||
openFile(): Promise<File[]> {
|
||||
return Promise.resolve(TestState.files as File[]);
|
||||
}
|
||||
|
||||
downloadFile(data: Blob, fileName: string): void {
|
||||
TestState.downloads.push({ data, fileName });
|
||||
}
|
||||
}
|
||||
|
||||
class TestMessageService implements IMessageService {
|
||||
show(options: IMessageProps): IDisposable {
|
||||
TestState.messages.push(options);
|
||||
return toDisposable(() => {});
|
||||
}
|
||||
|
||||
remove(): void {
|
||||
// no container in command tests
|
||||
}
|
||||
|
||||
removeAll(): void {
|
||||
TestState.messages = [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestWorkbook {
|
||||
getUnitId() {
|
||||
return 'focused-workbook';
|
||||
}
|
||||
|
||||
getSheetBySheetName(sheetName: string) {
|
||||
if (sheetName !== 'Recorded Sheet') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { getSheetId: () => 'actual-sheet-id' };
|
||||
}
|
||||
|
||||
getActiveSheet() {
|
||||
return { getSheetId: () => 'active-sheet-id' };
|
||||
}
|
||||
|
||||
getSheetBySheetId(sheetId: string) {
|
||||
if (sheetId !== 'sheet-1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { getName: () => 'Recorded Sheet' };
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
private readonly _workbook = new TestWorkbook();
|
||||
|
||||
getFocusedUnit() {
|
||||
return this._workbook;
|
||||
}
|
||||
|
||||
getUnit() {
|
||||
return this._workbook;
|
||||
}
|
||||
}
|
||||
|
||||
function createCommandTestBed() {
|
||||
const injector = new Injector();
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
|
||||
injector.add([IMessageService, { useClass: TestMessageService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([ActionRecorderService]);
|
||||
injector.add([ActionReplayService]);
|
||||
|
||||
const logService = injector.get(ILogService);
|
||||
logService.setLogLevel(LogLevel.SILENT);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(StartRecordingActionCommand);
|
||||
commandService.registerCommand(CompleteRecordingActionCommand);
|
||||
commandService.registerCommand(StopRecordingActionCommand);
|
||||
commandService.registerCommand(ReplayLocalRecordCommand);
|
||||
commandService.registerCommand(ReplayLocalRecordOnNamesakeCommand);
|
||||
commandService.registerCommand(ReplayLocalRecordOnActiveCommand);
|
||||
commandService.registerCommand({
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: (_accessor, params?: IAppliedCommandParams) => {
|
||||
TestState.appliedParams.push({ ...params });
|
||||
return true;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
commandService,
|
||||
recorderService: injector.get(ActionRecorderService),
|
||||
};
|
||||
}
|
||||
|
||||
describe('action-recorder commands', () => {
|
||||
beforeEach(() => {
|
||||
TestState.reset();
|
||||
});
|
||||
|
||||
it('records whitelisted user commands and exports them through the complete command', async () => {
|
||||
const { commandService, recorderService } = createCommandTestBed();
|
||||
const recordingStates: boolean[] = [];
|
||||
const commandIdsVisibleInPanel: string[][] = [];
|
||||
recorderService.recording$.subscribe((state) => recordingStates.push(state));
|
||||
recorderService.recordedCommands$.subscribe((commands) => {
|
||||
const ids: string[] = [];
|
||||
for (const command of commands) {
|
||||
ids.push(command.id);
|
||||
}
|
||||
commandIdsVisibleInPanel.push(ids);
|
||||
});
|
||||
recorderService.registerRecordedCommand({
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => true,
|
||||
});
|
||||
|
||||
await commandService.executeCommand(StartRecordingActionCommand.id, { replaceId: true });
|
||||
await commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'sheet-1',
|
||||
value: '42',
|
||||
});
|
||||
await commandService.executeCommand(CompleteRecordingActionCommand.id);
|
||||
|
||||
expect(recordingStates.at(-1)).toBe(false);
|
||||
expect(commandIdsVisibleInPanel.at(-1)).toEqual([]);
|
||||
expect(TestState.downloads).toHaveLength(1);
|
||||
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
|
||||
|
||||
const exportedCommands = JSON.parse(await TestState.downloads[0].data.text());
|
||||
expect(exportedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'Recorded Sheet',
|
||||
value: '42',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses the same finalized export flow when the user stops recording', async () => {
|
||||
const { commandService, recorderService } = createCommandTestBed();
|
||||
|
||||
await commandService.executeCommand(StartRecordingActionCommand.id);
|
||||
expect(recorderService.recording).toBe(true);
|
||||
|
||||
await commandService.executeCommand(StopRecordingActionCommand.id);
|
||||
|
||||
expect(recorderService.recording).toBe(false);
|
||||
expect(TestState.downloads).toHaveLength(1);
|
||||
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
|
||||
});
|
||||
|
||||
it('replays local records against the matching sheet name', async () => {
|
||||
const { commandService } = createCommandTestBed();
|
||||
TestState.files = [{
|
||||
name: 'recorded-commands.json',
|
||||
text: () => Promise.resolve(JSON.stringify([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'old-workbook',
|
||||
subUnitId: 'Recorded Sheet',
|
||||
value: 'from-file',
|
||||
},
|
||||
},
|
||||
])),
|
||||
}];
|
||||
|
||||
const result = await commandService.executeCommand(ReplayLocalRecordOnNamesakeCommand.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestState.appliedParams).toEqual([{
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'actual-sheet-id',
|
||||
value: 'from-file',
|
||||
}]);
|
||||
expect(TestState.messages).toEqual([{
|
||||
type: MessageType.Success,
|
||||
content: 'Successfully replayed local records',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('replays local records against the active sheet when requested', async () => {
|
||||
const { commandService } = createCommandTestBed();
|
||||
TestState.files = [{
|
||||
name: 'recorded-commands.json',
|
||||
text: () => Promise.resolve(JSON.stringify([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'old-workbook',
|
||||
subUnitId: 'any-recorded-sheet',
|
||||
value: 'active-sheet-value',
|
||||
},
|
||||
},
|
||||
])),
|
||||
}];
|
||||
|
||||
const result = await commandService.executeCommand(ReplayLocalRecordOnActiveCommand.id);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestState.appliedParams).toEqual([{
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'active-sheet-id',
|
||||
value: 'active-sheet-value',
|
||||
}]);
|
||||
});
|
||||
|
||||
it('does not announce replay success when the user cancels local file selection', async () => {
|
||||
const { commandService } = createCommandTestBed();
|
||||
|
||||
const result = await commandService.executeCommand(ReplayLocalRecordCommand.id);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(TestState.appliedParams).toEqual([]);
|
||||
expect(TestState.messages).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommand } from '@univerjs/core';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
import { ActionRecorderService } from '../../services/action-recorder.service';
|
||||
|
||||
interface IStartRecordingActionCommandParams {
|
||||
replaceId?: boolean;
|
||||
}
|
||||
|
||||
export const StartRecordingActionCommand: ICommand<IStartRecordingActionCommandParams> = {
|
||||
id: 'action-recorder.command.start-recording',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor, params) => {
|
||||
const actionRecorderService = accessor.get(ActionRecorderService);
|
||||
actionRecorderService.startRecording(!!params?.replaceId);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export const CompleteRecordingActionCommand: ICommand = {
|
||||
id: 'action-recorder.command.complete-recording',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor) => {
|
||||
const actionRecorderService = accessor.get(ActionRecorderService);
|
||||
actionRecorderService.completeRecording();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export const StopRecordingActionCommand: ICommand = {
|
||||
id: 'action-recorder.command.stop-recording',
|
||||
type: CommandType.COMMAND,
|
||||
handler: (accessor) => {
|
||||
const actionRecorderService = accessor.get(ActionRecorderService);
|
||||
actionRecorderService.completeRecording();
|
||||
return true;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommand } from '@univerjs/core';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
import { MessageType } from '@univerjs/design';
|
||||
import { IMessageService } from '@univerjs/ui';
|
||||
import { ActionReplayService, ReplayMode } from '../../services/replay.service';
|
||||
|
||||
export const ReplayLocalRecordCommand: ICommand = {
|
||||
id: 'action-recorder.command.replay-local-records',
|
||||
type: CommandType.COMMAND,
|
||||
handler: async (accessor) => {
|
||||
const replayService = accessor.get(ActionReplayService);
|
||||
const result = await replayService.replayLocalJSON();
|
||||
|
||||
if (result) {
|
||||
const messageService = accessor.get(IMessageService);
|
||||
messageService.show({
|
||||
type: MessageType.Success,
|
||||
content: 'Successfully replayed local records',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
export const ReplayLocalRecordOnNamesakeCommand: ICommand = {
|
||||
id: 'action-recorder.command.replay-local-records-name',
|
||||
type: CommandType.COMMAND,
|
||||
handler: async (accessor) => {
|
||||
const replayService = accessor.get(ActionReplayService);
|
||||
const result = await replayService.replayLocalJSON(ReplayMode.NAME);
|
||||
|
||||
if (result) {
|
||||
const messageService = accessor.get(IMessageService);
|
||||
messageService.show({
|
||||
type: MessageType.Success,
|
||||
content: 'Successfully replayed local records',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
|
||||
export const ReplayLocalRecordOnActiveCommand: ICommand = {
|
||||
id: 'action-recorder.command.replay-local-records-active',
|
||||
type: CommandType.COMMAND,
|
||||
handler: async (accessor) => {
|
||||
const replayService = accessor.get(ActionReplayService);
|
||||
const result = await replayService.replayLocalJSON(ReplayMode.ACTIVE);
|
||||
|
||||
if (result) {
|
||||
const messageService = accessor.get(IMessageService);
|
||||
messageService.show({
|
||||
type: MessageType.Success,
|
||||
content: 'Successfully replayed local records',
|
||||
});
|
||||
}
|
||||
|
||||
return result;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandService,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LogLevel,
|
||||
} from '@univerjs/core';
|
||||
import { ILocalFileService } from '@univerjs/ui';
|
||||
import { beforeEach, describe, expect, it } from 'vitest';
|
||||
import { ActionRecorderService } from '../../../services/action-recorder.service';
|
||||
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../operation';
|
||||
|
||||
class TestLocalFileService implements ILocalFileService {
|
||||
static downloads = 0;
|
||||
|
||||
static reset() {
|
||||
this.downloads = 0;
|
||||
}
|
||||
|
||||
openFile(): Promise<File[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
downloadFile(): void {
|
||||
TestLocalFileService.downloads += 1;
|
||||
}
|
||||
}
|
||||
|
||||
class TestFocusedUnit {
|
||||
getUnitId() {
|
||||
return 'focused-workbook';
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
private readonly _focusedUnit = new TestFocusedUnit();
|
||||
|
||||
getFocusedUnit() {
|
||||
return this._focusedUnit;
|
||||
}
|
||||
}
|
||||
|
||||
function createOperationTestBed() {
|
||||
const injector = new Injector();
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([ActionRecorderService]);
|
||||
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand(OpenRecordPanelOperation);
|
||||
commandService.registerCommand(CloseRecordPanelOperation);
|
||||
|
||||
return {
|
||||
commandService,
|
||||
recorderService: injector.get(ActionRecorderService),
|
||||
};
|
||||
}
|
||||
|
||||
describe('action-recorder panel operations', () => {
|
||||
beforeEach(() => {
|
||||
TestLocalFileService.reset();
|
||||
});
|
||||
|
||||
it('opens the record panel and closes an active recording session without exporting', async () => {
|
||||
const { commandService, recorderService } = createOperationTestBed();
|
||||
const panelStates: boolean[] = [];
|
||||
recorderService.panelOpened$.subscribe((state) => panelStates.push(state));
|
||||
|
||||
await commandService.executeCommand(OpenRecordPanelOperation.id);
|
||||
recorderService.startRecording();
|
||||
await commandService.executeCommand(CloseRecordPanelOperation.id);
|
||||
|
||||
expect(panelStates).toEqual([false, true, false]);
|
||||
expect(recorderService.recording).toBe(false);
|
||||
expect(TestLocalFileService.downloads).toBe(0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IOperation } from '@univerjs/core';
|
||||
import { CommandType } from '@univerjs/core';
|
||||
import { ActionRecorderService } from '../../services/action-recorder.service';
|
||||
|
||||
export const OpenRecordPanelOperation: IOperation = {
|
||||
id: 'action-recorder.operation.open-panel',
|
||||
type: CommandType.OPERATION,
|
||||
handler(accessor) {
|
||||
const s = accessor.get(ActionRecorderService);
|
||||
s.togglePanel(true);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
|
||||
export const CloseRecordPanelOperation: IOperation = {
|
||||
id: 'action-recorder.operation.close-panel',
|
||||
type: CommandType.OPERATION,
|
||||
handler(accessor) {
|
||||
const s = accessor.get(ActionRecorderService);
|
||||
s.togglePanel(false);
|
||||
return true;
|
||||
},
|
||||
};
|
||||
28
packages/action-recorder/src/config/config.ts
Normal file
28
packages/action-recorder/src/config/config.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { MenuConfig } from '@univerjs/ui';
|
||||
|
||||
export const ACTION_RECORDER_PLUGIN_CONFIG_KEY = 'action-recorder.config';
|
||||
|
||||
export const configSymbol = Symbol(ACTION_RECORDER_PLUGIN_CONFIG_KEY);
|
||||
|
||||
export interface IUniverActionRecorderConfig {
|
||||
menu?: MenuConfig;
|
||||
replayOnly?: boolean;
|
||||
}
|
||||
|
||||
export const defaultPluginConfig: IUniverActionRecorderConfig = {};
|
||||
@@ -0,0 +1,199 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Disposable, ICommandService, Inject, Injector } from '@univerjs/core';
|
||||
import {
|
||||
AddWorksheetMergeAllCommand,
|
||||
AddWorksheetMergeCommand,
|
||||
AddWorksheetMergeHorizontalCommand,
|
||||
AddWorksheetMergeVerticalCommand,
|
||||
AutoFillCommand,
|
||||
CancelFrozenCommand,
|
||||
CopySheetCommand,
|
||||
DeleteRangeMoveLeftCommand,
|
||||
DeleteRangeMoveUpCommand,
|
||||
DeltaColumnWidthCommand,
|
||||
DeltaRowHeightCommand,
|
||||
InsertColAfterCommand,
|
||||
InsertColBeforeCommand,
|
||||
InsertRowAfterCommand,
|
||||
InsertRowBeforeCommand,
|
||||
InsertSheetCommand,
|
||||
RefillCommand,
|
||||
RemoveSheetCommand,
|
||||
SetFrozenCommand,
|
||||
SetHorizontalTextAlignCommand,
|
||||
SetOverlineCommand,
|
||||
SetRangeValuesCommand,
|
||||
SetSelectionsOperation,
|
||||
SetStrikeThroughCommand,
|
||||
SetStyleCommand,
|
||||
SetTextColorCommand,
|
||||
SetTextRotationCommand,
|
||||
SetTextWrapCommand,
|
||||
SetVerticalTextAlignCommand,
|
||||
SetWorksheetActivateCommand,
|
||||
SetWorksheetActiveOperation,
|
||||
} from '@univerjs/sheets';
|
||||
import { RemoveSheetFilterCommand, SetSheetFilterRangeCommand, SetSheetsFilterCriteriaCommand } from '@univerjs/sheets-filter';
|
||||
import {
|
||||
SetRangeBoldCommand,
|
||||
SetRangeFontFamilyCommand,
|
||||
SetRangeFontSizeCommand,
|
||||
SetRangeItalicCommand,
|
||||
SetRangeStrickThroughCommand,
|
||||
SetRangeSubscriptCommand,
|
||||
SetRangeSuperscriptCommand,
|
||||
SetRangeTextColorCommand,
|
||||
SetRangeUnderlineCommand,
|
||||
SheetCopyCommand,
|
||||
SheetCutCommand,
|
||||
SheetPasteBesidesBorderCommand,
|
||||
SheetPasteColWidthCommand,
|
||||
SheetPasteCommand,
|
||||
SheetPasteFormatCommand,
|
||||
SheetPasteShortKeyCommand,
|
||||
SheetPasteValueCommand,
|
||||
} from '@univerjs/sheets-ui';
|
||||
import {
|
||||
BuiltInUIPart,
|
||||
connectInjector,
|
||||
IMenuManagerService,
|
||||
IUIPartsService,
|
||||
} from '@univerjs/ui';
|
||||
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../commands/commands/record.command';
|
||||
import { ReplayLocalRecordCommand, ReplayLocalRecordOnActiveCommand, ReplayLocalRecordOnNamesakeCommand } from '../commands/commands/replay.command';
|
||||
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../commands/operations/operation';
|
||||
import { menuSchema } from '../menu/action-recorder.menu';
|
||||
import { ActionRecorderService } from '../services/action-recorder.service';
|
||||
import { RecorderPanel } from '../views/components/RecorderPanel';
|
||||
|
||||
export class ActionRecorderController extends Disposable {
|
||||
constructor(
|
||||
@ICommandService private readonly _commandSrv: ICommandService,
|
||||
@IUIPartsService private readonly _uiPartsSrv: IUIPartsService,
|
||||
@IMenuManagerService private readonly _menuManagerService: IMenuManagerService,
|
||||
@Inject(ActionRecorderService) private readonly _actionRecorderService: ActionRecorderService,
|
||||
@Inject(Injector) private readonly _injector: Injector
|
||||
) {
|
||||
super();
|
||||
|
||||
this._initCommands();
|
||||
this._initUI();
|
||||
this._initSheetsCommands();
|
||||
this._initDocsCommands();
|
||||
}
|
||||
|
||||
private _initCommands(): void {
|
||||
([
|
||||
StartRecordingActionCommand,
|
||||
StopRecordingActionCommand,
|
||||
CompleteRecordingActionCommand,
|
||||
OpenRecordPanelOperation,
|
||||
CloseRecordPanelOperation,
|
||||
ReplayLocalRecordCommand,
|
||||
ReplayLocalRecordOnNamesakeCommand,
|
||||
ReplayLocalRecordOnActiveCommand,
|
||||
]).forEach((command) => this._commandSrv.registerCommand(command));
|
||||
}
|
||||
|
||||
private _initUI(): void {
|
||||
this._uiPartsSrv.registerComponent(BuiltInUIPart.GLOBAL, () => connectInjector(RecorderPanel, this._injector));
|
||||
this._menuManagerService.mergeMenu(menuSchema);
|
||||
}
|
||||
|
||||
private _initSheetsCommands(): void {
|
||||
([
|
||||
// InsertColCommand,
|
||||
// InsertRowCommand,
|
||||
// #region basic commands
|
||||
CopySheetCommand,
|
||||
DeleteRangeMoveLeftCommand,
|
||||
DeleteRangeMoveUpCommand,
|
||||
DeltaColumnWidthCommand,
|
||||
DeltaRowHeightCommand,
|
||||
InsertSheetCommand,
|
||||
InsertColAfterCommand,
|
||||
InsertColBeforeCommand,
|
||||
InsertRowAfterCommand,
|
||||
InsertRowBeforeCommand,
|
||||
RemoveSheetCommand,
|
||||
SetStyleCommand,
|
||||
AddWorksheetMergeCommand,
|
||||
AddWorksheetMergeAllCommand,
|
||||
AddWorksheetMergeVerticalCommand,
|
||||
AddWorksheetMergeHorizontalCommand,
|
||||
// ResetBackgroundColorCommand,
|
||||
// ResetTextColorCommand,
|
||||
// SetBackgroundColorCommand,
|
||||
// SetBoldCommand,
|
||||
// SetFontFamilyCommand,
|
||||
// SetFontSizeCommand,
|
||||
SetFrozenCommand,
|
||||
CancelFrozenCommand,
|
||||
SetHorizontalTextAlignCommand,
|
||||
// SetItalicCommand,
|
||||
SetOverlineCommand,
|
||||
SetRangeBoldCommand,
|
||||
SetRangeFontFamilyCommand,
|
||||
SetRangeFontSizeCommand,
|
||||
SetRangeItalicCommand,
|
||||
SetRangeStrickThroughCommand,
|
||||
SetRangeSubscriptCommand,
|
||||
SetRangeSuperscriptCommand,
|
||||
SetRangeTextColorCommand,
|
||||
SetRangeUnderlineCommand,
|
||||
SetRangeValuesCommand,
|
||||
SetStrikeThroughCommand,
|
||||
SetTextColorCommand,
|
||||
SetTextRotationCommand,
|
||||
SetTextWrapCommand,
|
||||
// SetUnderlineCommand,
|
||||
SetVerticalTextAlignCommand,
|
||||
SheetCopyCommand,
|
||||
SheetCutCommand,
|
||||
SheetPasteBesidesBorderCommand,
|
||||
SheetPasteColWidthCommand,
|
||||
SheetPasteCommand,
|
||||
SheetPasteFormatCommand,
|
||||
SheetPasteShortKeyCommand,
|
||||
SheetPasteValueCommand,
|
||||
AutoFillCommand,
|
||||
RefillCommand,
|
||||
|
||||
SetWorksheetActivateCommand,
|
||||
SetWorksheetActiveOperation,
|
||||
SetSelectionsOperation,
|
||||
// #endregion
|
||||
|
||||
// #region data validation command
|
||||
// #endregion
|
||||
|
||||
// #region conditional formatting command
|
||||
// #endregion
|
||||
|
||||
// #region filter command
|
||||
SetSheetFilterRangeCommand,
|
||||
SetSheetsFilterCriteriaCommand,
|
||||
RemoveSheetFilterCommand,
|
||||
// #endregion
|
||||
]).forEach((command) => this._actionRecorderService.registerRecordedCommand(command));
|
||||
}
|
||||
|
||||
private _initDocsCommands(): void {
|
||||
// TODO
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Disposable, Inject } from '@univerjs/core';
|
||||
import { RecordIcon } from '@univerjs/icons';
|
||||
import { ComponentManager, IconManager } from '@univerjs/ui';
|
||||
|
||||
export class ComponentsController extends Disposable {
|
||||
constructor(
|
||||
@Inject(ComponentManager) private readonly _componentManager: ComponentManager,
|
||||
@Inject(IconManager) private readonly _iconManager: IconManager
|
||||
) {
|
||||
super();
|
||||
|
||||
this._registerIcons();
|
||||
}
|
||||
|
||||
private _registerIcons(): void {
|
||||
this.disposeWithMe(this._iconManager.register({
|
||||
RecordIcon,
|
||||
}));
|
||||
}
|
||||
}
|
||||
2
packages/action-recorder/src/global.css
Normal file
2
packages/action-recorder/src/global.css
Normal file
@@ -0,0 +1,2 @@
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
23
packages/action-recorder/src/index.ts
Normal file
23
packages/action-recorder/src/index.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import './global.css';
|
||||
|
||||
export type { IUniverActionRecorderConfig } from './config/config';
|
||||
export { menuSchema as ActionRecorderMenuSchema } from './menu/action-recorder.menu';
|
||||
export { UniverActionRecorderPlugin } from './plugin';
|
||||
export { ActionRecorderService } from './services/action-recorder.service';
|
||||
export { ActionReplayService } from './services/replay.service';
|
||||
40
packages/action-recorder/src/locale/ar-SA.ts
Normal file
40
packages/action-recorder/src/locale/ar-SA.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'جارٍ التسجيل...',
|
||||
startRecording: 'بدء التسجيل',
|
||||
cancel: 'إلغاء',
|
||||
close: 'إغلاق',
|
||||
save: 'حفظ',
|
||||
start: 'بدء',
|
||||
startWithId: 'بدء (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'تسجيل الإجراءات',
|
||||
record: 'تسجيل الإجراءات...',
|
||||
'replay-local': 'استبدال السجل المحلي...',
|
||||
'replay-local-name': 'استبدال السجل المحلي بوحدة فرعية...',
|
||||
'replay-local-active': 'استبدال السجل المحلي بالوحدة الفرعية الحالية...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/ca-ES.ts
Normal file
40
packages/action-recorder/src/locale/ca-ES.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'S\'està enregistrant...',
|
||||
startRecording: 'Inicia l’enregistrament',
|
||||
cancel: 'Cancel·la',
|
||||
close: 'Tanca',
|
||||
save: 'Desa',
|
||||
start: 'Inicia',
|
||||
startWithId: 'Inicia (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Grava accions',
|
||||
record: 'Grava accions...',
|
||||
'replay-local': 'Reemplaça gravació local...',
|
||||
'replay-local-name': 'Reemplaça gravació local per subunitat...',
|
||||
'replay-local-active': 'Reemplaça gravació local per subunitat actual...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/de-DE.ts
Normal file
40
packages/action-recorder/src/locale/de-DE.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Aufnahme läuft...',
|
||||
startRecording: 'Aufnahme starten',
|
||||
cancel: 'Abbrechen',
|
||||
close: 'Schließen',
|
||||
save: 'Speichern',
|
||||
start: 'Starten',
|
||||
startWithId: 'Starten (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Aktionen aufzeichnen',
|
||||
record: 'Aktionen aufzeichnen...',
|
||||
'replay-local': 'Lokale Aufzeichnung ersetzen...',
|
||||
'replay-local-name': 'Lokale Aufzeichnung nach Untereinheit ersetzen...',
|
||||
'replay-local-active': 'Lokale Aufzeichnung nach aktueller Untereinheit ersetzen...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
38
packages/action-recorder/src/locale/en-US.ts
Normal file
38
packages/action-recorder/src/locale/en-US.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const locale = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Recording...',
|
||||
startRecording: 'Start Recording',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
save: 'Save',
|
||||
start: 'Start',
|
||||
startWithId: 'Start(N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Record Actions',
|
||||
record: 'Record Actions...',
|
||||
'replay-local': 'Replace Local Record...',
|
||||
'replay-local-name': 'Replace Local Record by Subunit...',
|
||||
'replay-local-active': 'Replace Local Record by Current Subunit...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/es-ES.ts
Normal file
40
packages/action-recorder/src/locale/es-ES.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Grabando...',
|
||||
startRecording: 'Iniciar grabación',
|
||||
cancel: 'Cancelar',
|
||||
close: 'Cerrar',
|
||||
save: 'Guardar',
|
||||
start: 'Iniciar',
|
||||
startWithId: 'Iniciar (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Grabar acciones',
|
||||
record: 'Grabar acciones...',
|
||||
'replay-local': 'Reemplazar grabación local...',
|
||||
'replay-local-name': 'Reemplazar grabación local por subunidad...',
|
||||
'replay-local-active': 'Reemplazar grabación local por subunidad actual...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/fa-IR.ts
Normal file
40
packages/action-recorder/src/locale/fa-IR.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'در حال ضبط...',
|
||||
startRecording: 'شروع ضبط',
|
||||
cancel: 'لغو',
|
||||
close: 'بستن',
|
||||
save: 'ذخیره',
|
||||
start: 'شروع',
|
||||
startWithId: 'شروع (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'ثبت اقدامات',
|
||||
record: 'ثبت اقدامات...',
|
||||
'replay-local': 'بازپخش محلی ثبت...',
|
||||
'replay-local-name': 'بازپخش محلی ثبت بر اساس نام زیر واحد...',
|
||||
'replay-local-active': 'بازپخش محلی ثبت بر اساس زیر واحد فعلی...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/fr-FR.ts
Normal file
40
packages/action-recorder/src/locale/fr-FR.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Enregistrement...',
|
||||
startRecording: 'Démarrer l’enregistrement',
|
||||
cancel: 'Annuler',
|
||||
close: 'Fermer',
|
||||
save: 'Enregistrer',
|
||||
start: 'Démarrer',
|
||||
startWithId: 'Démarrer (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Enregistrer les actions',
|
||||
record: 'Enregistrer les actions...',
|
||||
'replay-local': 'Lecture de l\'enregistrement local...',
|
||||
'replay-local-name': 'Remplacer l\'enregistrement local par le sous-unité...',
|
||||
'replay-local-active': 'Remplacer l\'enregistrement local par le sous-unité actuel...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/id-ID.ts
Normal file
40
packages/action-recorder/src/locale/id-ID.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Merekam...',
|
||||
startRecording: 'Mulai merekam',
|
||||
cancel: 'Batal',
|
||||
close: 'Tutup',
|
||||
save: 'Simpan',
|
||||
start: 'Mulai',
|
||||
startWithId: 'Mulai (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Rekam Aksi',
|
||||
record: 'Rekam Aksi...',
|
||||
'replay-local': 'Putar Ulang Rekaman Lokal...',
|
||||
'replay-local-name': 'Putar Ulang Rekaman Lokal berdasarkan Subunit...',
|
||||
'replay-local-active': 'Putar Ulang Rekaman Lokal berdasarkan Subunit Saat Ini...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/it-IT.ts
Normal file
40
packages/action-recorder/src/locale/it-IT.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Registrazione...',
|
||||
startRecording: 'Avvia registrazione',
|
||||
cancel: 'Annulla',
|
||||
close: 'Chiudi',
|
||||
save: 'Salva',
|
||||
start: 'Avvia',
|
||||
startWithId: 'Avvia (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Registra Azioni',
|
||||
record: 'Registra Azioni...',
|
||||
'replay-local': 'Riproduci Registrazione Locale...',
|
||||
'replay-local-name': 'Riproduci Registrazione Locale per Sottoelemento...',
|
||||
'replay-local-active': 'Riproduci Registrazione Locale per Sottoelemento Corrente...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/ja-JP.ts
Normal file
40
packages/action-recorder/src/locale/ja-JP.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: '記録中...',
|
||||
startRecording: '記録を開始',
|
||||
cancel: 'キャンセル',
|
||||
close: '閉じる',
|
||||
save: '保存',
|
||||
start: '開始',
|
||||
startWithId: '開始 (N)',
|
||||
},
|
||||
menu: {
|
||||
title: '操作記録',
|
||||
record: '記録を開始...',
|
||||
'replay-local': 'ローカルの記録で置き換え...',
|
||||
'replay-local-name': 'サブ単位ごとにローカルの記録で置き換え...',
|
||||
'replay-local-active': '現在のサブ単位をローカルの記録で置き換え...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/ko-KR.ts
Normal file
40
packages/action-recorder/src/locale/ko-KR.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: '기록 중...',
|
||||
startRecording: '기록 시작',
|
||||
cancel: '취소',
|
||||
close: '닫기',
|
||||
save: '저장',
|
||||
start: '시작',
|
||||
startWithId: '시작 (N)',
|
||||
},
|
||||
menu: {
|
||||
title: '동작 기록',
|
||||
record: '동작 기록 시작...',
|
||||
'replay-local': '로컬 기록 대체...',
|
||||
'replay-local-name': '하위 단위로 로컬 기록 대체...',
|
||||
'replay-local-active': '현재 하위 단위로 로컬 기록 대체...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/pl-PL.ts
Normal file
40
packages/action-recorder/src/locale/pl-PL.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Nagrywanie...',
|
||||
startRecording: 'Rozpocznij nagrywanie',
|
||||
cancel: 'Anuluj',
|
||||
close: 'Zamknij',
|
||||
save: 'Zapisz',
|
||||
start: 'Rozpocznij',
|
||||
startWithId: 'Rozpocznij (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Nagraj działania',
|
||||
record: 'Nagraj działania...',
|
||||
'replay-local': 'Odtwórz nagranie lokalne...',
|
||||
'replay-local-name': 'Odtwórz nagranie lokalne według jednostki podrzędnej...',
|
||||
'replay-local-active': 'Odtwórz nagranie lokalne według bieżącej jednostki podrzędnej...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/pt-BR.ts
Normal file
40
packages/action-recorder/src/locale/pt-BR.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Gravando...',
|
||||
startRecording: 'Iniciar gravação',
|
||||
cancel: 'Cancelar',
|
||||
close: 'Fechar',
|
||||
save: 'Salvar',
|
||||
start: 'Iniciar',
|
||||
startWithId: 'Iniciar (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Gravar Ações',
|
||||
record: 'Gravar Ações...',
|
||||
'replay-local': 'Substituir Gravação Local...',
|
||||
'replay-local-name': 'Substituir Gravação Local por Subunidade...',
|
||||
'replay-local-active': 'Substituir Gravação Local pela Subunidade Atual...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/ru-RU.ts
Normal file
40
packages/action-recorder/src/locale/ru-RU.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Запись...',
|
||||
startRecording: 'Начать запись',
|
||||
cancel: 'Отмена',
|
||||
close: 'Закрыть',
|
||||
save: 'Сохранить',
|
||||
start: 'Начать',
|
||||
startWithId: 'Начать (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Запись действий',
|
||||
record: 'Записать действия...',
|
||||
'replay-local': 'Заменить локальную запись...',
|
||||
'replay-local-name': 'Заменить локальную запись по подразделению...',
|
||||
'replay-local-active': 'Заменить локальную запись по текущему подразделению...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/sk-SK.ts
Normal file
40
packages/action-recorder/src/locale/sk-SK.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Nahrávanie...',
|
||||
startRecording: 'Spustiť nahrávanie',
|
||||
cancel: 'Zrušiť',
|
||||
close: 'Zavrieť',
|
||||
save: 'Uložiť',
|
||||
start: 'Spustiť',
|
||||
startWithId: 'Spustiť (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Zaznamenať akcie',
|
||||
record: 'Zaznamenať akcie...',
|
||||
'replay-local': 'Nahradiť miestny záznam...',
|
||||
'replay-local-name': 'Nahradiť miestny záznam podľa podjednotky...',
|
||||
'replay-local-active': 'Nahradiť miestny záznam podľa aktuálnej podjednotky...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
20
packages/action-recorder/src/locale/types.ts
Normal file
20
packages/action-recorder/src/locale/types.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { LocaleLeafKeys } from '@univerjs/core';
|
||||
import type enUS from './en-US';
|
||||
|
||||
export type LocaleKey = LocaleLeafKeys<typeof enUS>;
|
||||
40
packages/action-recorder/src/locale/vi-VN.ts
Normal file
40
packages/action-recorder/src/locale/vi-VN.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: 'Đang ghi...',
|
||||
startRecording: 'Bắt đầu ghi',
|
||||
cancel: 'Hủy',
|
||||
close: 'Đóng',
|
||||
save: 'Lưu',
|
||||
start: 'Bắt đầu',
|
||||
startWithId: 'Bắt đầu (N)',
|
||||
},
|
||||
menu: {
|
||||
title: 'Ghi hành động',
|
||||
record: 'Ghi hành động...',
|
||||
'replay-local': 'Thay bản ghi cục bộ...',
|
||||
'replay-local-name': 'Thay bản ghi cục bộ theo bộ phận...',
|
||||
'replay-local-active': 'Thay bản ghi cục bộ theo bộ phận hiện tại...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/zh-CN.ts
Normal file
40
packages/action-recorder/src/locale/zh-CN.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: '录制中...',
|
||||
startRecording: '开始录制',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
save: '保存',
|
||||
start: '开始',
|
||||
startWithId: '开始 (N)',
|
||||
},
|
||||
menu: {
|
||||
title: '操作录制',
|
||||
record: '录制操作...',
|
||||
'replay-local': '回放本地录制...',
|
||||
'replay-local-name': '根据子表名回放本地录制...',
|
||||
'replay-local-active': '根据当前子表回放本地录制...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/zh-HK.ts
Normal file
40
packages/action-recorder/src/locale/zh-HK.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: '錄製中...',
|
||||
startRecording: '開始錄製',
|
||||
cancel: '取消',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
start: '開始',
|
||||
startWithId: '開始 (N)',
|
||||
},
|
||||
menu: {
|
||||
title: '操作錄製',
|
||||
record: '錄製操作...',
|
||||
'replay-local': '回放本地錄製...',
|
||||
'replay-local-name': '根據子表名回放本地錄製...',
|
||||
'replay-local-active': '根據當前子表回放本地錄製...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
40
packages/action-recorder/src/locale/zh-TW.ts
Normal file
40
packages/action-recorder/src/locale/zh-TW.ts
Normal file
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type enUS from './en-US';
|
||||
|
||||
const locale: typeof enUS = {
|
||||
'action-recorder': {
|
||||
panel: {
|
||||
recording: '錄製中...',
|
||||
startRecording: '開始錄製',
|
||||
cancel: '取消',
|
||||
close: '關閉',
|
||||
save: '儲存',
|
||||
start: '開始',
|
||||
startWithId: '開始 (N)',
|
||||
},
|
||||
menu: {
|
||||
title: '操作錄製',
|
||||
record: '錄製操作...',
|
||||
'replay-local': '回放本地錄製...',
|
||||
'replay-local-name': '根據子表名回放本地錄製...',
|
||||
'replay-local-active': '根據當前子表回放本地錄製...',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export default locale;
|
||||
98
packages/action-recorder/src/menu/action-recorder.menu.ts
Normal file
98
packages/action-recorder/src/menu/action-recorder.menu.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IAccessor } from '@univerjs/core';
|
||||
import type { IMenuButtonItem, IMenuSelectorItem, MenuSchemaType } from '@univerjs/ui';
|
||||
import type { LocaleKey } from '../locale/types';
|
||||
import { MenuItemType, RibbonOthersGroup } from '@univerjs/ui';
|
||||
import {
|
||||
ReplayLocalRecordCommand,
|
||||
ReplayLocalRecordOnActiveCommand,
|
||||
ReplayLocalRecordOnNamesakeCommand,
|
||||
} from '../commands/commands/replay.command';
|
||||
import { OpenRecordPanelOperation } from '../commands/operations/operation';
|
||||
import { ActionRecorderService } from '../services/action-recorder.service';
|
||||
|
||||
export const RECORD_MENU_ITEM_ID = 'RECORD_MENU_ITEM';
|
||||
export function RecordMenuItemFactory(): IMenuSelectorItem<LocaleKey> {
|
||||
return {
|
||||
id: RECORD_MENU_ITEM_ID,
|
||||
type: MenuItemType.SUBITEMS,
|
||||
icon: 'RecordIcon',
|
||||
tooltip: 'action-recorder.menu.title',
|
||||
};
|
||||
}
|
||||
|
||||
export function OpenRecorderMenuItemFactory(accessor: IAccessor): IMenuButtonItem<LocaleKey> {
|
||||
const actionRecorderService = accessor.get(ActionRecorderService);
|
||||
|
||||
return {
|
||||
id: OpenRecordPanelOperation.id,
|
||||
title: 'action-recorder.menu.record',
|
||||
type: MenuItemType.BUTTON,
|
||||
disabled$: actionRecorderService.panelOpened$,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReplayLocalRecordMenuItemFactory(): IMenuButtonItem<LocaleKey> {
|
||||
return {
|
||||
id: ReplayLocalRecordCommand.id,
|
||||
title: 'action-recorder.menu.replay-local',
|
||||
type: MenuItemType.BUTTON,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReplayLocalRecordOnNamesakeMenuItemFactory(): IMenuButtonItem<LocaleKey> {
|
||||
return {
|
||||
id: ReplayLocalRecordOnNamesakeCommand.id,
|
||||
title: 'action-recorder.menu.replay-local-name',
|
||||
type: MenuItemType.BUTTON,
|
||||
};
|
||||
}
|
||||
|
||||
export function ReplayLocalRecordOnActiveMenuItemFactory(): IMenuButtonItem<LocaleKey> {
|
||||
return {
|
||||
id: ReplayLocalRecordOnActiveCommand.id,
|
||||
title: 'action-recorder.menu.replay-local-active',
|
||||
type: MenuItemType.BUTTON,
|
||||
};
|
||||
}
|
||||
|
||||
export const menuSchema: MenuSchemaType = {
|
||||
[RibbonOthersGroup.OTHERS]: {
|
||||
[RECORD_MENU_ITEM_ID]: {
|
||||
order: 1,
|
||||
menuItemFactory: RecordMenuItemFactory,
|
||||
[OpenRecordPanelOperation.id]: {
|
||||
order: 1,
|
||||
menuItemFactory: OpenRecorderMenuItemFactory,
|
||||
},
|
||||
[ReplayLocalRecordCommand.id]: {
|
||||
order: 2,
|
||||
menuItemFactory: ReplayLocalRecordMenuItemFactory,
|
||||
},
|
||||
[ReplayLocalRecordOnNamesakeCommand.id]: {
|
||||
order: 3,
|
||||
menuItemFactory: ReplayLocalRecordOnNamesakeMenuItemFactory,
|
||||
},
|
||||
[ReplayLocalRecordOnActiveCommand.id]: {
|
||||
order: 4,
|
||||
menuItemFactory: ReplayLocalRecordOnActiveMenuItemFactory,
|
||||
},
|
||||
},
|
||||
|
||||
},
|
||||
};
|
||||
75
packages/action-recorder/src/plugin.ts
Normal file
75
packages/action-recorder/src/plugin.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency } from '@univerjs/core';
|
||||
import type { IUniverActionRecorderConfig } from './config/config';
|
||||
import { IConfigService, Inject, Injector, merge, Plugin } from '@univerjs/core';
|
||||
import pkg from '../package.json';
|
||||
import { ACTION_RECORDER_PLUGIN_CONFIG_KEY, defaultPluginConfig } from './config/config';
|
||||
import { ActionRecorderController } from './controllers/action-recorder.controller';
|
||||
import { ComponentsController } from './controllers/components.controller';
|
||||
import { ActionRecorderService } from './services/action-recorder.service';
|
||||
import { ActionReplayService } from './services/replay.service';
|
||||
|
||||
/**
|
||||
* This plugin provides a recorder for user's interactions with Univer,
|
||||
* it only records commands (and some special operations) so that it can be replayed later.
|
||||
*/
|
||||
|
||||
export class UniverActionRecorderPlugin extends Plugin {
|
||||
static override pluginName = 'UNIVER_ACTION_RECORDER_PLUGIN';
|
||||
static override packageName = pkg.name;
|
||||
static override version = pkg.version;
|
||||
|
||||
constructor(
|
||||
private readonly _config: Partial<IUniverActionRecorderConfig> = defaultPluginConfig,
|
||||
@Inject(Injector) protected readonly _injector: Injector,
|
||||
@IConfigService private readonly _configService: IConfigService
|
||||
) {
|
||||
super();
|
||||
|
||||
// Manage the plugin configuration.
|
||||
const { menu, ...rest } = merge(
|
||||
{},
|
||||
defaultPluginConfig,
|
||||
this._config
|
||||
);
|
||||
if (menu) {
|
||||
this._configService.setConfig('menu', menu, { merge: true });
|
||||
}
|
||||
this._configService.setConfig(ACTION_RECORDER_PLUGIN_CONFIG_KEY, rest);
|
||||
}
|
||||
|
||||
override onStarting(): void {
|
||||
this._injector.add([ComponentsController]);
|
||||
this._injector.get(ComponentsController);
|
||||
const dependency = this._config.replayOnly
|
||||
? [[ActionReplayService]]
|
||||
: [
|
||||
[ActionRecorderService],
|
||||
[ActionReplayService],
|
||||
[ActionRecorderController],
|
||||
];
|
||||
(dependency as Dependency[]).forEach((d) => this._injector.add(d));
|
||||
}
|
||||
|
||||
override onSteady(): void {
|
||||
if (this._config.replayOnly) {
|
||||
return;
|
||||
}
|
||||
this._injector.get(ActionRecorderController);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { SetSelectionsOperation } from '@univerjs/sheets';
|
||||
import { DesktopLocalFileService, ILocalFileService } from '@univerjs/ui';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ActionRecorderService } from '../action-recorder.service';
|
||||
|
||||
class TestUniverInstanceService {
|
||||
getFocusedUnit() {
|
||||
return { getUnitId: () => 'unit-1' };
|
||||
}
|
||||
|
||||
getUnit() {
|
||||
return {
|
||||
getSheetBySheetId: () => ({ getName: () => 'Sheet-A' }),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
describe('ActionRecorderService', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('should record commands, replace selection entries and complete/stop', () => {
|
||||
const link = { click: vi.fn(), download: '', href: '' };
|
||||
vi.stubGlobal('document', { createElement: () => link });
|
||||
vi.stubGlobal('window', { URL: { createObjectURL: () => 'blob:recording' } });
|
||||
|
||||
const injector = new Injector();
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ILocalFileService, { useClass: DesktopLocalFileService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([ActionRecorderService]);
|
||||
const service = injector.get(ActionRecorderService);
|
||||
const commandService = injector.get(ICommandService);
|
||||
const logService = injector.get(ILogService);
|
||||
const logError = vi.spyOn(logService, 'error');
|
||||
|
||||
expect(() =>
|
||||
service.registerRecordedCommand({
|
||||
id: 'mutation-id',
|
||||
type: CommandType.MUTATION,
|
||||
} as never)
|
||||
).toThrow('[CommandRecorderService] Cannot record mutation commands.');
|
||||
|
||||
service.registerRecordedCommand({ id: 'cmd-1', type: CommandType.COMMAND } as never);
|
||||
service.registerRecordedCommand({ id: SetSelectionsOperation.id, type: CommandType.OPERATION } as never);
|
||||
commandService.registerCommand({
|
||||
id: 'cmd-1',
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => true,
|
||||
});
|
||||
commandService.registerCommand({
|
||||
id: SetSelectionsOperation.id,
|
||||
type: CommandType.OPERATION,
|
||||
handler: () => true,
|
||||
});
|
||||
commandService.registerCommand({
|
||||
id: 'ignored',
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => true,
|
||||
});
|
||||
|
||||
const panelStates: boolean[] = [];
|
||||
const recordingStates: boolean[] = [];
|
||||
const commandStates: string[][] = [];
|
||||
service.panelOpened$.subscribe((v) => panelStates.push(v));
|
||||
service.recording$.subscribe((v) => recordingStates.push(v));
|
||||
service.recordedCommands$.subscribe((v) => commandStates.push(v.map((cmd) => cmd.id)));
|
||||
|
||||
service.togglePanel(true);
|
||||
service.startRecording(true);
|
||||
expect(service.recording).toBe(true);
|
||||
expect(recordingStates[recordingStates.length - 1]).toBe(true);
|
||||
|
||||
commandService.syncExecuteCommand('cmd-1', {
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sheet-1',
|
||||
});
|
||||
commandService.syncExecuteCommand(SetSelectionsOperation.id, {
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sheet-1',
|
||||
mark: 1,
|
||||
});
|
||||
commandService.syncExecuteCommand(SetSelectionsOperation.id, {
|
||||
unitId: 'unit-1',
|
||||
subUnitId: 'sheet-1',
|
||||
mark: 2,
|
||||
});
|
||||
commandService.syncExecuteCommand('ignored', {});
|
||||
|
||||
expect(commandStates[commandStates.length - 1]).toEqual(['cmd-1']);
|
||||
|
||||
service.completeRecording();
|
||||
expect(link.download).toBe('recorded-commands.json');
|
||||
expect(link.href).toBe('blob:recording');
|
||||
expect(link.click).toHaveBeenCalledTimes(1);
|
||||
expect(logError).toHaveBeenCalled();
|
||||
expect(recordingStates[recordingStates.length - 1]).toBe(false);
|
||||
|
||||
service.startRecording();
|
||||
commandService.syncExecuteCommand('cmd-1', {
|
||||
params: { unitId: 'unit-1', subUnitId: 'sheet-1' },
|
||||
});
|
||||
service.togglePanel(false);
|
||||
expect(panelStates[panelStates.length - 1]).toBe(false);
|
||||
expect(recordingStates[recordingStates.length - 1]).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,394 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommandInfo } from '@univerjs/core';
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
} from '@univerjs/core';
|
||||
import { MessageType } from '@univerjs/design';
|
||||
import { ILocalFileService, IMessageService } from '@univerjs/ui';
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { ActionReplayService, ReplayMode } from '../replay.service';
|
||||
|
||||
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.test.apply-cell-value';
|
||||
const STOP_REPLAY_COMMAND_ID = 'action-recorder.test.stop-replay';
|
||||
|
||||
interface IAppliedCommand {
|
||||
id: string;
|
||||
params?: {
|
||||
unitId?: string;
|
||||
subUnitId?: string;
|
||||
value?: string;
|
||||
};
|
||||
}
|
||||
|
||||
interface ITestFile {
|
||||
name: string;
|
||||
text(): Promise<string>;
|
||||
}
|
||||
|
||||
class TestReplayState {
|
||||
static appliedCommands: IAppliedCommand[] = [];
|
||||
|
||||
static reset() {
|
||||
this.appliedCommands = [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestLocalFileService {
|
||||
static files: ITestFile[] = [];
|
||||
|
||||
static reset() {
|
||||
this.files = [];
|
||||
}
|
||||
|
||||
openFile() {
|
||||
return Promise.resolve(TestLocalFileService.files);
|
||||
}
|
||||
}
|
||||
|
||||
class TestMessageService {
|
||||
static messages: Array<{ type: MessageType; content: string }> = [];
|
||||
|
||||
static reset() {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
show(message: { type: MessageType; content: string }) {
|
||||
TestMessageService.messages.push(message);
|
||||
}
|
||||
}
|
||||
|
||||
class TestWorkbook {
|
||||
getUnitId() {
|
||||
return 'focused-workbook';
|
||||
}
|
||||
|
||||
getSheetBySheetName(sheetName: string) {
|
||||
if (sheetName !== 'Recorded Sheet') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { getSheetId: () => 'actual-sheet-id' };
|
||||
}
|
||||
|
||||
getActiveSheet() {
|
||||
return { getSheetId: () => 'active-sheet-id' };
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
private readonly _workbook = new TestWorkbook();
|
||||
|
||||
getFocusedUnit() {
|
||||
return this._workbook;
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceServiceWithoutFocusedUnit {
|
||||
getFocusedUnit() {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function cloneCommandParams(params?: IAppliedCommand['params']) {
|
||||
if (!params) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return { ...params };
|
||||
}
|
||||
|
||||
function createJsonFile(name: string, commands: ICommandInfo[]): ITestFile {
|
||||
return {
|
||||
name,
|
||||
text: () => Promise.resolve(JSON.stringify(commands)),
|
||||
};
|
||||
}
|
||||
|
||||
function createInvalidJsonFile(name: string): ITestFile {
|
||||
return {
|
||||
name,
|
||||
text: () => Promise.resolve('{ invalid json'),
|
||||
};
|
||||
}
|
||||
|
||||
function createReplayTestBed(instanceService: typeof TestUniverInstanceService | typeof TestUniverInstanceServiceWithoutFocusedUnit = TestUniverInstanceService) {
|
||||
const injector = new Injector();
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IMessageService, { useClass: TestMessageService as never }]);
|
||||
injector.add([ILocalFileService, { useClass: TestLocalFileService as never }]);
|
||||
injector.add([IUniverInstanceService, { useClass: instanceService as never }]);
|
||||
injector.add([ActionReplayService]);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
commandService.registerCommand({
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: (_accessor, params?: IAppliedCommand['params']) => {
|
||||
TestReplayState.appliedCommands.push({
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: cloneCommandParams(params),
|
||||
});
|
||||
return true;
|
||||
},
|
||||
});
|
||||
commandService.registerCommand({
|
||||
id: STOP_REPLAY_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: (_accessor, params?: IAppliedCommand['params']) => {
|
||||
TestReplayState.appliedCommands.push({
|
||||
id: STOP_REPLAY_COMMAND_ID,
|
||||
params: cloneCommandParams(params),
|
||||
});
|
||||
return false;
|
||||
},
|
||||
});
|
||||
|
||||
return {
|
||||
commandService,
|
||||
replayService: injector.get(ActionReplayService),
|
||||
};
|
||||
}
|
||||
|
||||
describe('ActionReplayService', () => {
|
||||
beforeEach(() => {
|
||||
TestReplayState.reset();
|
||||
TestLocalFileService.reset();
|
||||
TestMessageService.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('replays recorded sheet actions into the focused workbook and resolves recorded sheet names', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
|
||||
const result = await replayService.replayCommands(
|
||||
[
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'Recorded Sheet',
|
||||
value: 'replayed by sheet name',
|
||||
},
|
||||
},
|
||||
],
|
||||
{ mode: ReplayMode.NAME }
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'actual-sheet-id',
|
||||
value: 'replayed by sheet name',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('can replay recorded actions into the active sheet instead of the recorded sheet', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
|
||||
const result = await replayService.replayCommands(
|
||||
[
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'replayed into active sheet',
|
||||
},
|
||||
},
|
||||
],
|
||||
{ mode: ReplayMode.ACTIVE }
|
||||
);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'active-sheet-id',
|
||||
value: 'replayed into active sheet',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops replaying after a command reports failure', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
|
||||
const result = await replayService.replayCommands([
|
||||
{
|
||||
id: STOP_REPLAY_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'cannot apply',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'must not be applied',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: STOP_REPLAY_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'cannot apply',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('loads a local JSON recording and replays it against the focused workbook', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
TestLocalFileService.files = [
|
||||
createJsonFile('recording.json', [
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'Recorded Sheet',
|
||||
value: 'loaded from json',
|
||||
},
|
||||
},
|
||||
]),
|
||||
];
|
||||
|
||||
const result = await replayService.replayLocalJSON(ReplayMode.NAME);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'actual-sheet-id',
|
||||
value: 'loaded from json',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not replay anything when the local recording cannot be parsed', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
TestLocalFileService.files = [createInvalidJsonFile('broken-recording.json')];
|
||||
|
||||
const result = await replayService.replayLocalJSON();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(TestReplayState.appliedCommands).toEqual([]);
|
||||
expect(TestMessageService.messages).toEqual([
|
||||
{
|
||||
type: MessageType.Error,
|
||||
content: 'Failed to replay commands from local file broken-recording.json.',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not replay anything when the user cancels local recording selection', async () => {
|
||||
const { replayService } = createReplayTestBed();
|
||||
TestLocalFileService.files = [];
|
||||
|
||||
const result = await replayService.replayLocalJSON();
|
||||
|
||||
expect(result).toBe(false);
|
||||
expect(TestReplayState.appliedCommands).toEqual([]);
|
||||
expect(TestMessageService.messages).toEqual([]);
|
||||
});
|
||||
|
||||
it('delays command execution while still targeting the focused workbook', async () => {
|
||||
vi.useFakeTimers();
|
||||
const { replayService } = createReplayTestBed();
|
||||
|
||||
const result = replayService.replayCommandsWithDelay([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'recorded-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'delayed replay',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(TestReplayState.appliedCommands).toEqual([]);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1000);
|
||||
|
||||
await expect(result).resolves.toBe(true);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'recorded-sheet-id',
|
||||
value: 'delayed replay',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps replaying commands without workbook params when no workbook is focused', async () => {
|
||||
const { replayService } = createReplayTestBed(TestUniverInstanceServiceWithoutFocusedUnit);
|
||||
|
||||
const result = await replayService.replayCommands([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
value: 'global action',
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(TestReplayState.appliedCommands).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
params: {
|
||||
value: 'global action',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
126
packages/action-recorder/src/services/action-recorder.service.ts
Normal file
126
packages/action-recorder/src/services/action-recorder.service.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommand, ICommandInfo, IDisposable, Nullable, Workbook } from '@univerjs/core';
|
||||
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
|
||||
import { CommandType, Disposable, ICommandService, ILogService, IUniverInstanceService } from '@univerjs/core';
|
||||
import { SetSelectionsOperation } from '@univerjs/sheets';
|
||||
import { ILocalFileService } from '@univerjs/ui';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
|
||||
/**
|
||||
* This service is for recording commands. What commands should be recorded can be configured by other
|
||||
* plugins.
|
||||
*/
|
||||
export class ActionRecorderService extends Disposable {
|
||||
private readonly _shouldRecordCommands = new Set<string>();
|
||||
|
||||
private readonly _panelOpened$ = new BehaviorSubject<boolean>(false);
|
||||
readonly panelOpened$ = this._panelOpened$.asObservable();
|
||||
|
||||
private _recorder: Nullable<IDisposable> = null;
|
||||
|
||||
private readonly _recording$ = new BehaviorSubject<boolean>(false);
|
||||
readonly recording$ = this._recording$.asObservable();
|
||||
get recording(): boolean { return this._recording$.getValue(); }
|
||||
|
||||
private _recorded$ = new BehaviorSubject<ICommandInfo[]>([]);
|
||||
private get _recorded(): ICommandInfo[] { return this._recorded$.getValue(); }
|
||||
|
||||
private _recordedCommands$ = new BehaviorSubject<ICommandInfo[]>([]);
|
||||
readonly recordedCommands$ = this._recordedCommands$.asObservable();
|
||||
private get _recordedCommands(): ICommandInfo[] { return this._recordedCommands$.getValue(); }
|
||||
|
||||
constructor(
|
||||
@ICommandService private readonly _commandSrv: ICommandService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@ILocalFileService private readonly _localFileService: ILocalFileService,
|
||||
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
registerRecordedCommand(command: ICommand): void {
|
||||
if (command.type === CommandType.MUTATION) throw new Error('[CommandRecorderService] Cannot record mutation commands.');
|
||||
this._shouldRecordCommands.add(command.id);
|
||||
}
|
||||
|
||||
togglePanel(visible: boolean): void {
|
||||
this._panelOpened$.next(visible);
|
||||
|
||||
if (visible === false) this.stopRecording();
|
||||
}
|
||||
|
||||
startRecording(replaceId = false): void {
|
||||
this._recorder = this._commandSrv.onCommandExecuted((rawCommandInfo) => {
|
||||
if (this._shouldRecordCommands.has(rawCommandInfo.id)) {
|
||||
const recorded = this._recorded;
|
||||
const commands = this._recordedCommands;
|
||||
|
||||
let commandInfo = { ...rawCommandInfo };
|
||||
|
||||
const focusUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
|
||||
const { unitId = focusUnitId, subUnitId } = commandInfo?.params as ISheetCommandSharedParams;
|
||||
|
||||
if (replaceId && unitId && subUnitId) {
|
||||
const subUnitName = (this._instanceService.getUnit(unitId) as Workbook).getSheetBySheetId(subUnitId)?.getName();
|
||||
commandInfo = {
|
||||
...commandInfo,
|
||||
params: {
|
||||
...commandInfo.params,
|
||||
subUnitId: subUnitName,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
commandInfo.id === SetSelectionsOperation.id &&
|
||||
recorded.length > 0 &&
|
||||
recorded[recorded.length - 1].id === SetSelectionsOperation.id
|
||||
) {
|
||||
recorded[recorded.length - 1] = commandInfo;
|
||||
} else {
|
||||
recorded.push(commandInfo);
|
||||
this._recorded$.next(recorded);
|
||||
|
||||
if (commandInfo.type === CommandType.COMMAND) {
|
||||
commands.push(commandInfo);
|
||||
this._recordedCommands$.next(commands);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._recording$.next(true);
|
||||
}
|
||||
|
||||
stopRecording(): void {
|
||||
this._recorder?.dispose();
|
||||
this._recorder = null;
|
||||
|
||||
this._recorded$.next([]);
|
||||
this._recordedCommands$.next([]);
|
||||
this._recording$.next(false);
|
||||
}
|
||||
|
||||
completeRecording(): void {
|
||||
const commands = this._recorded.slice();
|
||||
this._localFileService.downloadFile(new Blob([JSON.stringify(commands, null, 2)]), 'recorded-commands.json');
|
||||
|
||||
this._logService.error('Recorded commands:', commands);
|
||||
this.stopRecording();
|
||||
}
|
||||
}
|
||||
150
packages/action-recorder/src/services/replay.service.ts
Normal file
150
packages/action-recorder/src/services/replay.service.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ICommandInfo, Workbook } from '@univerjs/core';
|
||||
import type { ISheetCommandSharedParams } from '@univerjs/sheets';
|
||||
import { awaitTime, Disposable, ICommandService, ILogService, IUniverInstanceService } from '@univerjs/core';
|
||||
import { MessageType } from '@univerjs/design';
|
||||
import { ILocalFileService, IMessageService } from '@univerjs/ui';
|
||||
|
||||
export enum ReplayMode {
|
||||
DEFAULT = 'default',
|
||||
NAME = 'name',
|
||||
ACTIVE = 'active',
|
||||
}
|
||||
|
||||
/**
|
||||
* This service is for replaying user actions.
|
||||
*/
|
||||
export class ActionReplayService extends Disposable {
|
||||
constructor(
|
||||
@IMessageService private readonly _messageService: IMessageService,
|
||||
@IUniverInstanceService private readonly _instanceService: IUniverInstanceService,
|
||||
@ILocalFileService private readonly _localFileService: ILocalFileService,
|
||||
@ILogService private readonly _logService: ILogService,
|
||||
@ICommandService private readonly _commandService: ICommandService
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a local file and try to replay commands in this JSON.
|
||||
*/
|
||||
async replayLocalJSON(mode: ReplayMode = ReplayMode.DEFAULT): Promise<boolean> {
|
||||
const files = await this._localFileService.openFile({ multiple: false, accept: '.json' });
|
||||
if (files.length !== 1) return false;
|
||||
|
||||
const file = files[0];
|
||||
try {
|
||||
return this.replayCommands(JSON.parse(await file.text()), { mode });
|
||||
} catch {
|
||||
this._messageService.show({
|
||||
type: MessageType.Error,
|
||||
content: `Failed to replay commands from local file ${file.name}.`,
|
||||
});
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a list of commands. Note that `unitId` of these commands would be changed to the focused unit.
|
||||
* @param commands - The commands to replay.
|
||||
* @returns If the replay is successful.
|
||||
*/
|
||||
async replayCommands(commands: ICommandInfo[], options?: { mode: ReplayMode }): Promise<boolean> {
|
||||
const focusedUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
|
||||
if (!focusedUnitId) {
|
||||
this._logService.error('[ReplayService]', 'no focused unit to replay commands');
|
||||
}
|
||||
|
||||
const { mode } = options || {};
|
||||
|
||||
for (const command of commands) {
|
||||
const { id, params } = command;
|
||||
const commandParams = params as ISheetCommandSharedParams;
|
||||
if (commandParams) {
|
||||
if (typeof commandParams.unitId !== 'undefined') {
|
||||
commandParams.unitId = focusedUnitId!;
|
||||
}
|
||||
|
||||
if (mode === ReplayMode.NAME && commandParams.subUnitId !== 'undefined') {
|
||||
const realSubUnitId = (this._instanceService.getFocusedUnit() as Workbook).getSheetBySheetName(commandParams.subUnitId)?.getSheetId();
|
||||
if (realSubUnitId) {
|
||||
commandParams.subUnitId = realSubUnitId;
|
||||
} else {
|
||||
this._logService.error('[ReplayService]', `failed to find subunit by subUnitName = ${commandParams.subUnitId}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (mode === ReplayMode.ACTIVE && commandParams.subUnitId !== 'undefined') {
|
||||
const realSubUnitId = (this._instanceService.getFocusedUnit() as Workbook).getActiveSheet()?.getSheetId();
|
||||
if (realSubUnitId) {
|
||||
commandParams.subUnitId = realSubUnitId;
|
||||
} else {
|
||||
this._logService.error('[ReplayService]', 'failed to find active subunit');
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this._commandService.executeCommand(id, params);
|
||||
if (!result) return false;
|
||||
} else {
|
||||
const result = await this._commandService.executeCommand(id);
|
||||
if (!result) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replay a list of commands with a random delay between each command.
|
||||
* @param commands - The commands to replay.
|
||||
*/
|
||||
async replayCommandsWithDelay(commands: ICommandInfo[]): Promise<boolean> {
|
||||
const focusedUnitId = this._instanceService.getFocusedUnit()?.getUnitId();
|
||||
if (!focusedUnitId) {
|
||||
this._logService.error('[ReplayService]', 'no focused unit to replay commands');
|
||||
}
|
||||
|
||||
for (const command of commands) {
|
||||
await awaitTime(randomBetween200And1k());
|
||||
|
||||
const { id, params } = command;
|
||||
if (params) {
|
||||
if (typeof (params as ISharedCommandParams).unitId !== 'undefined') {
|
||||
(params as ISharedCommandParams).unitId = focusedUnitId;
|
||||
}
|
||||
|
||||
const result = await this._commandService.executeCommand(id, params);
|
||||
if (!result) return false;
|
||||
} else {
|
||||
const result = await this._commandService.executeCommand(id);
|
||||
if (!result) return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
interface ISharedCommandParams {
|
||||
unitId?: string;
|
||||
}
|
||||
|
||||
function randomBetween200And1k() {
|
||||
return Math.floor(Math.random() * 800) + 200;
|
||||
}
|
||||
101
packages/action-recorder/src/views/components/RecorderPanel.tsx
Normal file
101
packages/action-recorder/src/views/components/RecorderPanel.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { LocaleKey } from '../../locale/types';
|
||||
import { ICommandService, LocaleService } from '@univerjs/core';
|
||||
import { Button } from '@univerjs/design';
|
||||
import { RecordIcon } from '@univerjs/icons';
|
||||
import { useDependency, useObservable } from '@univerjs/ui';
|
||||
import { useCallback } from 'react';
|
||||
import { CompleteRecordingActionCommand, StartRecordingActionCommand, StopRecordingActionCommand } from '../../commands/commands/record.command';
|
||||
import { CloseRecordPanelOperation } from '../../commands/operations/operation';
|
||||
import { ActionRecorderService } from '../../services/action-recorder.service';
|
||||
|
||||
export function RecorderPanel() {
|
||||
const s = useDependency(ActionRecorderService);
|
||||
const opened = useObservable(s.panelOpened$);
|
||||
|
||||
if (!opened) return null;
|
||||
return <RecordPanelImpl />;
|
||||
}
|
||||
|
||||
function RecordPanelImpl() {
|
||||
const commandService = useDependency(ICommandService);
|
||||
const localeService = useDependency(LocaleService);
|
||||
const actionRecorderService = useDependency(ActionRecorderService);
|
||||
|
||||
const recording = useObservable(actionRecorderService.recording$);
|
||||
const recordedCommands = useObservable(actionRecorderService.recordedCommands$);
|
||||
const len = recordedCommands?.length ?? 0;
|
||||
|
||||
const closePanel = useCallback(() => {
|
||||
if (!recording) commandService.executeCommand(CloseRecordPanelOperation.id);
|
||||
}, [commandService, recording]);
|
||||
|
||||
const startRecording = useCallback((replaceId?: boolean) => {
|
||||
if (!recording) commandService.executeCommand(StartRecordingActionCommand.id, { replaceId });
|
||||
}, [commandService, recording]);
|
||||
|
||||
const completeRecording = useCallback(() => {
|
||||
if (recording) commandService.executeCommand(CompleteRecordingActionCommand.id);
|
||||
}, [commandService, recording]);
|
||||
|
||||
const stopRecording = useCallback(() => {
|
||||
if (recording) commandService.executeCommand(StopRecordingActionCommand.id);
|
||||
}, [commandService, recording]);
|
||||
|
||||
const titleText = recording
|
||||
? len === 0 ? localeService.t<LocaleKey>('action-recorder.panel.recording') : (`${len}: ${recordedCommands![len - 1].id}`)
|
||||
: localeService.t<LocaleKey>('action-recorder.panel.startRecording');
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`
|
||||
univer-fixed univer-bottom-20 univer-left-1/2 univer-z-[1000] univer-flex univer-h-16 univer-w-[512px]
|
||||
-univer-translate-x-1/2 univer-items-center univer-rounded-lg univer-bg-gray-0 univer-px-5
|
||||
univer-shadow-lg
|
||||
`}
|
||||
>
|
||||
<div className="univer-mr-2 univer-size-5 univer-shrink-0 univer-grow-0 univer-text-xl">
|
||||
<RecordIcon />
|
||||
</div>
|
||||
<div className="univer-flex-1 univer-text-sm">{titleText}</div>
|
||||
<div className="univer-flex univer-w-64 univer-shrink-0 univer-grow-0 univer-justify-between">
|
||||
<Button
|
||||
className="univer-w-20"
|
||||
onClick={recording ? stopRecording : closePanel}
|
||||
>
|
||||
{localeService.t<LocaleKey>(recording ? 'action-recorder.panel.cancel' : 'action-recorder.panel.close')}
|
||||
</Button>
|
||||
<Button
|
||||
className="univer-w-20"
|
||||
variant="primary"
|
||||
onClick={recording ? completeRecording : () => startRecording()}
|
||||
>
|
||||
{localeService.t<LocaleKey>(recording ? 'action-recorder.panel.save' : 'action-recorder.panel.start')}
|
||||
</Button>
|
||||
{!recording && (
|
||||
<Button
|
||||
variant="primary"
|
||||
onClick={() => startRecording(true)}
|
||||
>
|
||||
{localeService.t<LocaleKey>('action-recorder.panel.startWithId')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,328 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IAccessor } from '@univerjs/core';
|
||||
import type { Root } from 'react-dom/client';
|
||||
import {
|
||||
CommandService,
|
||||
CommandType,
|
||||
ConfigService,
|
||||
ContextService,
|
||||
DesktopLogService,
|
||||
ICommandService,
|
||||
IConfigService,
|
||||
IContextService,
|
||||
ILogService,
|
||||
Injector,
|
||||
IUniverInstanceService,
|
||||
LocaleService,
|
||||
LocaleType,
|
||||
LogLevel,
|
||||
} from '@univerjs/core';
|
||||
import { ILocalFileService, RediContext } from '@univerjs/ui';
|
||||
import { act } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
import {
|
||||
CompleteRecordingActionCommand,
|
||||
StartRecordingActionCommand,
|
||||
StopRecordingActionCommand,
|
||||
} from '../../../commands/commands/record.command';
|
||||
import { CloseRecordPanelOperation, OpenRecordPanelOperation } from '../../../commands/operations/operation';
|
||||
import enUS from '../../../locale/en-US';
|
||||
import { ActionRecorderService } from '../../../services/action-recorder.service';
|
||||
import { RecorderPanel } from '../RecorderPanel';
|
||||
|
||||
(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
const APPLY_CELL_VALUE_COMMAND_ID = 'action-recorder.panel-test.apply-cell-value';
|
||||
|
||||
interface IAppliedCommandParams {
|
||||
unitId?: string;
|
||||
subUnitId?: string;
|
||||
value?: string;
|
||||
}
|
||||
|
||||
class TestState {
|
||||
static downloads: Array<{ data: Blob; fileName: string }> = [];
|
||||
static appliedParams: IAppliedCommandParams[] = [];
|
||||
|
||||
static reset() {
|
||||
this.downloads = [];
|
||||
this.appliedParams = [];
|
||||
}
|
||||
}
|
||||
|
||||
class TestLocalFileService implements ILocalFileService {
|
||||
openFile(): Promise<File[]> {
|
||||
return Promise.resolve([]);
|
||||
}
|
||||
|
||||
downloadFile(data: Blob, fileName: string): void {
|
||||
TestState.downloads.push({ data, fileName });
|
||||
}
|
||||
}
|
||||
|
||||
class TestWorkbook {
|
||||
getUnitId() {
|
||||
return 'focused-workbook';
|
||||
}
|
||||
|
||||
getSheetBySheetId(sheetId: string) {
|
||||
if (sheetId !== 'sheet-1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { getName: () => 'Recorded Sheet' };
|
||||
}
|
||||
}
|
||||
|
||||
class TestUniverInstanceService {
|
||||
private readonly _workbook = new TestWorkbook();
|
||||
|
||||
getFocusedUnit() {
|
||||
return this._workbook;
|
||||
}
|
||||
|
||||
getUnit() {
|
||||
return this._workbook;
|
||||
}
|
||||
}
|
||||
|
||||
function createRecorderPanelTestBed() {
|
||||
const injector = new Injector();
|
||||
injector.add([ICommandService, { useClass: CommandService }]);
|
||||
injector.add([ILogService, { useClass: DesktopLogService }]);
|
||||
injector.add([IContextService, { useClass: ContextService }]);
|
||||
injector.add([IConfigService, { useClass: ConfigService }]);
|
||||
injector.add([ILocalFileService, { useClass: TestLocalFileService }]);
|
||||
injector.add([IUniverInstanceService, { useClass: TestUniverInstanceService as never }]);
|
||||
injector.add([LocaleService]);
|
||||
injector.add([ActionRecorderService]);
|
||||
|
||||
injector.get(LocaleService).load({ [LocaleType.EN_US]: enUS });
|
||||
injector.get(LocaleService).setLocale(LocaleType.EN_US);
|
||||
|
||||
injector.get(ILogService).setLogLevel(LogLevel.SILENT);
|
||||
|
||||
const commandService = injector.get(ICommandService);
|
||||
[
|
||||
OpenRecordPanelOperation,
|
||||
CloseRecordPanelOperation,
|
||||
StartRecordingActionCommand,
|
||||
CompleteRecordingActionCommand,
|
||||
StopRecordingActionCommand,
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: (_accessor: IAccessor, params?: IAppliedCommandParams) => {
|
||||
TestState.appliedParams.push({ ...params });
|
||||
return true;
|
||||
},
|
||||
},
|
||||
].forEach((command) => commandService.registerCommand(command));
|
||||
|
||||
const recorderService = injector.get(ActionRecorderService);
|
||||
recorderService.registerRecordedCommand({
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
handler: () => true,
|
||||
});
|
||||
|
||||
return {
|
||||
injector,
|
||||
commandService,
|
||||
recorderService,
|
||||
};
|
||||
}
|
||||
|
||||
async function renderRecorderPanel(root: Root, injector: Injector) {
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<RediContext.Provider value={{ injector }}>
|
||||
<RecorderPanel />
|
||||
</RediContext.Provider>
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function getButton(container: HTMLElement, text: string) {
|
||||
const button = Array.from(container.querySelectorAll('[data-u-comp="button"]'))
|
||||
.find((item) => item.textContent === text);
|
||||
expect(button).toBeTruthy();
|
||||
return button as HTMLElement;
|
||||
}
|
||||
|
||||
describe('RecorderPanel', () => {
|
||||
let root: Root | undefined;
|
||||
let container: HTMLDivElement | undefined;
|
||||
let currentTestBed: ReturnType<typeof createRecorderPanelTestBed> | undefined;
|
||||
|
||||
beforeEach(() => {
|
||||
TestState.reset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
act(() => {
|
||||
root?.unmount();
|
||||
});
|
||||
container?.remove();
|
||||
currentTestBed = undefined;
|
||||
root = undefined;
|
||||
container = undefined;
|
||||
});
|
||||
|
||||
it('opens the panel and starts recording from the Start action', async () => {
|
||||
currentTestBed = createRecorderPanelTestBed();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await renderRecorderPanel(root, currentTestBed.injector);
|
||||
expect(container.textContent).toBe('');
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
|
||||
});
|
||||
expect(container.textContent).toContain('Start Recording');
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(currentTestBed.recorderService.recording).toBe(true);
|
||||
expect(container.textContent).toContain('Recording...');
|
||||
expect(container.textContent).toContain('Cancel');
|
||||
expect(container.textContent).toContain('Save');
|
||||
});
|
||||
|
||||
it('records a command with sheet names from Start(N) and exports it on Save', async () => {
|
||||
currentTestBed = createRecorderPanelTestBed();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await renderRecorderPanel(root, currentTestBed.injector);
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Start(N)').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'sheet-1',
|
||||
value: '42',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(`1: ${APPLY_CELL_VALUE_COMMAND_ID}`);
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Save').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(currentTestBed.recorderService.recording).toBe(false);
|
||||
expect(TestState.downloads).toHaveLength(1);
|
||||
expect(TestState.downloads[0].fileName).toBe('recorded-commands.json');
|
||||
expect(JSON.parse(await TestState.downloads[0].data.text())).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'Recorded Sheet',
|
||||
value: '42',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('stops recording without exporting when the panel is closed', async () => {
|
||||
currentTestBed = createRecorderPanelTestBed();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await renderRecorderPanel(root, currentTestBed.injector);
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(CloseRecordPanelOperation.id);
|
||||
});
|
||||
|
||||
expect(currentTestBed.recorderService.recording).toBe(false);
|
||||
expect(container.textContent).toBe('');
|
||||
expect(TestState.downloads).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('completes an active recording from the panel cancel action using the stop command', async () => {
|
||||
currentTestBed = createRecorderPanelTestBed();
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
root = createRoot(container);
|
||||
|
||||
await renderRecorderPanel(root, currentTestBed.injector);
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(OpenRecordPanelOperation.id);
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Start').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await currentTestBed!.commandService.executeCommand(APPLY_CELL_VALUE_COMMAND_ID, {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'sheet-1',
|
||||
value: 'cancelled',
|
||||
});
|
||||
});
|
||||
|
||||
expect(container.textContent).toContain(`1: ${APPLY_CELL_VALUE_COMMAND_ID}`);
|
||||
|
||||
await act(async () => {
|
||||
getButton(container!, 'Cancel').dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
});
|
||||
|
||||
expect(currentTestBed.recorderService.recording).toBe(false);
|
||||
expect(container.textContent).toContain('Start Recording');
|
||||
expect(TestState.downloads).toHaveLength(1);
|
||||
expect(JSON.parse(await TestState.downloads[0].data.text())).toEqual([
|
||||
{
|
||||
id: APPLY_CELL_VALUE_COMMAND_ID,
|
||||
type: CommandType.COMMAND,
|
||||
params: {
|
||||
unitId: 'focused-workbook',
|
||||
subUnitId: 'sheet-1',
|
||||
value: 'cancelled',
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
27
packages/action-recorder/tailwind.config.ts
Normal file
27
packages/action-recorder/tailwind.config.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Config } from 'tailwindcss';
|
||||
import preset from '@univerjs-infra/shared/tailwind';
|
||||
|
||||
const config: Config = {
|
||||
presets: [preset],
|
||||
content: [
|
||||
'./src/**/*.{js,ts,jsx,tsx}',
|
||||
],
|
||||
};
|
||||
|
||||
export default config;
|
||||
8
packages/action-recorder/tsconfig.json
Normal file
8
packages/action-recorder/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/base",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
18
packages/action-recorder/tsconfig.node.json
Normal file
18
packages/action-recorder/tsconfig.node.json
Normal file
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"extends": "@univerjs-infra/shared/tsconfigs/node",
|
||||
"compilerOptions": {
|
||||
"rootDir": "src",
|
||||
"declaration": true,
|
||||
"emitDeclarationOnly": true,
|
||||
"noEmit": false,
|
||||
"outDir": "lib/types"
|
||||
},
|
||||
"include": [
|
||||
"src"
|
||||
],
|
||||
"exclude": [
|
||||
"src/**/__tests__/**",
|
||||
"src/**/*.spec.ts",
|
||||
"src/**/*.test.ts"
|
||||
]
|
||||
}
|
||||
3
packages/action-recorder/vitest.config.ts
Normal file
3
packages/action-recorder/vitest.config.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
import createConfig from '@univerjs-infra/shared/vitest';
|
||||
|
||||
export default createConfig();
|
||||
39
packages/core/README.md
Normal file
39
packages/core/README.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# @univerjs/core
|
||||
|
||||
[](https://npmjs.com/package/@univerjs/core)
|
||||
[](https://npmjs.com/package/@univerjs/core)
|
||||
[](https://npmjs.com/package/@univerjs/core)
|
||||
|
||||
`@univerjs/core` is the foundation of Univer. It provides the Univer runtime, dependency injection, command and mutation services, data models, configuration, localization, and the shared Facade entry point used by higher-level packages.
|
||||
|
||||
## Package Overview
|
||||
|
||||
| Package | UMD global | CSS | Locales | Facade entry |
|
||||
| --- | --- | :---: | :---: | :---: |
|
||||
| `@univerjs/core` | `UniverCore` | No | No | Yes |
|
||||
|
||||
## Installation
|
||||
|
||||
```sh
|
||||
pnpm add @univerjs/core
|
||||
# or
|
||||
npm install @univerjs/core
|
||||
```
|
||||
|
||||
Keep all `@univerjs/*` packages on the same version.
|
||||
|
||||
## Usage
|
||||
|
||||
```ts
|
||||
import { LocaleType, Univer } from '@univerjs/core';
|
||||
|
||||
const univer = new Univer({
|
||||
locale: LocaleType.EN_US,
|
||||
});
|
||||
```
|
||||
|
||||
## Resources
|
||||
|
||||
- [Documentation](https://docs.univer.ai)
|
||||
- [NPM package](https://npmjs.com/package/@univerjs/core)
|
||||
- [GitHub repository](https://github.com/dream-num/univer)
|
||||
100
packages/core/package.json
Normal file
100
packages/core/package.json
Normal file
@@ -0,0 +1,100 @@
|
||||
{
|
||||
"name": "@univerjs/core",
|
||||
"version": "1.0.0-rc.0",
|
||||
"private": false,
|
||||
"description": "Core runtime, data models, command system, dependency injection, and Facade APIs for Univer.",
|
||||
"author": "DreamNum Co., Ltd. <developer@univer.ai>",
|
||||
"license": "Apache-2.0",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/univer"
|
||||
},
|
||||
"homepage": "https://univer.ai",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dream-num/univer"
|
||||
},
|
||||
"bugs": {
|
||||
"url": "https://github.com/dream-num/univer/issues"
|
||||
},
|
||||
"keywords": [
|
||||
"univer",
|
||||
"office-sdk",
|
||||
"core",
|
||||
"runtime",
|
||||
"facade"
|
||||
],
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./*": "./src/*",
|
||||
"./facade": "./src/facade/index.ts"
|
||||
},
|
||||
"main": "./src/index.ts",
|
||||
"types": "./lib/types/index.d.ts",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"main": "./lib/es/index.js",
|
||||
"module": "./lib/es/index.js",
|
||||
"exports": {
|
||||
".": {
|
||||
"import": "./lib/es/index.js",
|
||||
"require": "./lib/cjs/index.js",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./*": {
|
||||
"import": "./lib/es/*",
|
||||
"require": "./lib/cjs/*",
|
||||
"types": "./lib/types/index.d.ts"
|
||||
},
|
||||
"./facade": {
|
||||
"import": "./lib/es/facade.js",
|
||||
"require": "./lib/cjs/facade.js",
|
||||
"types": "./lib/types/facade/index.d.ts"
|
||||
},
|
||||
"./lib/facade": {
|
||||
"import": "./lib/es/facade.js",
|
||||
"require": "./lib/cjs/facade.js",
|
||||
"types": "./lib/types/facade/index.d.ts"
|
||||
},
|
||||
"./lib/*": "./lib/*"
|
||||
}
|
||||
},
|
||||
"directories": {
|
||||
"lib": "lib"
|
||||
},
|
||||
"files": [
|
||||
"lib"
|
||||
],
|
||||
"scripts": {
|
||||
"test": "vitest run",
|
||||
"test:watch": "vitest",
|
||||
"coverage": "vitest run --coverage",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"build:bundle": "univer-cli build",
|
||||
"build:types": "tsc -p tsconfig.node.json",
|
||||
"build": "pnpm run build:bundle && pnpm run build:types"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"rxjs": ">=7.0.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@univerjs/protocol": "workspace:*",
|
||||
"@univerjs/themes": "workspace:*",
|
||||
"@wendellhu/redi": "1.1.3",
|
||||
"async-lock": "^1.4.1",
|
||||
"fast-diff": "1.3.0",
|
||||
"kdbush": "^4.1.0",
|
||||
"lodash-es": "^4.18.1",
|
||||
"ot-json1": "^1.0.2",
|
||||
"rbush": "^4.0.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/async-lock": "^1.4.2",
|
||||
"@types/lodash-es": "^4.17.12",
|
||||
"@types/rbush": "^4.0.0",
|
||||
"@univerjs-infra/shared": "workspace:*",
|
||||
"rxjs": "^7.8.2",
|
||||
"typescript": "^6.0.3",
|
||||
"vitest": "^5.0.0"
|
||||
}
|
||||
}
|
||||
151
packages/core/src/__tests__/univer-skeleton.spec.ts
Normal file
151
packages/core/src/__tests__/univer-skeleton.spec.ts
Normal file
@@ -0,0 +1,151 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ILocales } from '../shared/locale';
|
||||
import { defaultTheme } from '@univerjs/themes';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { Injector } from '../common/di';
|
||||
import { UniverInstanceType } from '../common/unit';
|
||||
import { COMMAND_LOG_EXECUTION_CONFIG_KEY } from '../services/command/command.service';
|
||||
import { IConfigService } from '../services/config/config.service';
|
||||
import { IUniverInstanceService } from '../services/instance/instance.service';
|
||||
import { LocaleService } from '../services/locale/locale.service';
|
||||
import { LogLevel } from '../services/log/log.service';
|
||||
import { RegionService } from '../services/region/region.service';
|
||||
import { IUndoRedoService, UNDO_REDO_HISTORY_LIMIT_CONFIG_KEY } from '../services/undoredo/undoredo.service';
|
||||
import { Skeleton } from '../skeleton';
|
||||
import { LocaleType } from '../types/enum/locale-type';
|
||||
import { Univer } from '../univer';
|
||||
|
||||
describe('Skeleton', () => {
|
||||
it('should update dirty state and release locale data on dispose', () => {
|
||||
const skeleton = new Skeleton(new LocaleService());
|
||||
|
||||
expect(skeleton.dirty).toBe(true);
|
||||
expect(skeleton.getFontLocale()).toBeUndefined();
|
||||
|
||||
skeleton.makeDirty(false);
|
||||
expect(skeleton.dirty).toBe(false);
|
||||
|
||||
skeleton.dispose();
|
||||
expect(skeleton.getFontLocale()).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Univer', () => {
|
||||
it('should apply constructor config and expose locale/config methods', () => {
|
||||
const locales: ILocales = {
|
||||
[LocaleType.EN_US]: {
|
||||
test: {
|
||||
greeting: 'Hello {0}',
|
||||
},
|
||||
},
|
||||
} as unknown as ILocales;
|
||||
|
||||
const univer = new Univer({
|
||||
theme: defaultTheme,
|
||||
darkMode: true,
|
||||
locales,
|
||||
locale: LocaleType.EN_US,
|
||||
region: LocaleType.FR_FR,
|
||||
direction: 'rtl',
|
||||
logLevel: LogLevel.VERBOSE,
|
||||
logCommandExecution: true,
|
||||
undoRedoHistoryLimit: 12,
|
||||
});
|
||||
|
||||
const injector = univer.__getInjector();
|
||||
const localeService = injector.get(LocaleService);
|
||||
const regionService = injector.get(RegionService);
|
||||
|
||||
expect(localeService.getCurrentLocale()).toBe(LocaleType.EN_US);
|
||||
expect(regionService.getCurrentRegion()).toBe(LocaleType.FR_FR);
|
||||
expect(localeService.getDirection()).toBe('rtl');
|
||||
expect(localeService.t('test.greeting', 'Univer')).toBe('Hello Univer');
|
||||
expect(injector.get(IConfigService).getConfig(COMMAND_LOG_EXECUTION_CONFIG_KEY)).toBe(true);
|
||||
expect(injector.get(IConfigService).getConfig(UNDO_REDO_HISTORY_LIMIT_CONFIG_KEY)).toBe(12);
|
||||
|
||||
univer.setLocale(LocaleType.ZH_CN);
|
||||
expect(localeService.getCurrentLocale()).toBe(LocaleType.ZH_CN);
|
||||
expect(regionService.getCurrentRegion()).toBe(LocaleType.FR_FR);
|
||||
expect(localeService.getDirection()).toBe('rtl');
|
||||
|
||||
univer.setRegion(LocaleType.JA_JP);
|
||||
expect(regionService.getCurrentRegion()).toBe(LocaleType.JA_JP);
|
||||
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('should apply the configured undo redo history limit', () => {
|
||||
const unitId = 'undo-redo-limit-doc';
|
||||
const univer = new Univer({ undoRedoHistoryLimit: 2 });
|
||||
univer.createUnit(UniverInstanceType.UNIVER_DOC, { id: unitId });
|
||||
|
||||
const injector = univer.__getInjector();
|
||||
injector.get(IUniverInstanceService).focusUnit(unitId);
|
||||
const undoRedoService = injector.get(IUndoRedoService);
|
||||
|
||||
for (let i = 0; i < 3; i++) {
|
||||
undoRedoService.pushUndoRedo({
|
||||
unitID: unitId,
|
||||
undoMutations: [],
|
||||
redoMutations: [],
|
||||
id: `item-${i}`,
|
||||
});
|
||||
}
|
||||
|
||||
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('item-2');
|
||||
undoRedoService.popUndoToRedo();
|
||||
expect(undoRedoService.pitchTopUndoElement()?.id).toBe('item-1');
|
||||
undoRedoService.popUndoToRedo();
|
||||
expect(undoRedoService.pitchTopUndoElement()).toBeNull();
|
||||
|
||||
univer.dispose();
|
||||
});
|
||||
|
||||
it('should support add/remove dispose callbacks', () => {
|
||||
const univer = new Univer();
|
||||
const removedCallback = vi.fn();
|
||||
const activeCallback = vi.fn();
|
||||
|
||||
const disposable = univer.onDispose(removedCallback);
|
||||
univer.onDispose(activeCallback);
|
||||
disposable.dispose();
|
||||
|
||||
univer.dispose();
|
||||
|
||||
expect(removedCallback).not.toHaveBeenCalled();
|
||||
expect(activeCallback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should delegate plugin registration for tuple-style APIs and support parent injector', () => {
|
||||
const parentInjector = new Injector([]);
|
||||
const univer = new Univer({}, parentInjector);
|
||||
const registerPluginSpy = vi
|
||||
.spyOn((univer as any)._pluginService, 'registerPlugin')
|
||||
.mockImplementation(() => undefined);
|
||||
|
||||
univer.registerPlugins([
|
||||
[class FakePluginA {} as never, { enabled: true }],
|
||||
[class FakePluginB {} as never],
|
||||
] as never);
|
||||
|
||||
expect(registerPluginSpy).toHaveBeenCalledTimes(2);
|
||||
|
||||
registerPluginSpy.mockRestore();
|
||||
univer.dispose();
|
||||
});
|
||||
});
|
||||
126
packages/core/src/bases/__tests__/base-data-model.spec.ts
Normal file
126
packages/core/src/bases/__tests__/base-data-model.spec.ts
Normal file
@@ -0,0 +1,126 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IBaseSnapshot } from '../typedef';
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { BaseDataModel } from '../base-data-model';
|
||||
import { BASE_RECORD_ID_FIELD_ID, createBaseRecordIdField } from '../record-identity';
|
||||
import { BaseFieldType } from '../typedef';
|
||||
|
||||
describe('BaseDataModel', () => {
|
||||
it('excludes field types without a public authoring contract', () => {
|
||||
expect(Object.values(BaseFieldType)).toContain('recordLink');
|
||||
expect(Object.values(BaseFieldType)).not.toEqual(expect.arrayContaining([
|
||||
'twoWayLink',
|
||||
'lookup',
|
||||
'summary',
|
||||
'location',
|
||||
'button',
|
||||
'flow',
|
||||
'barcode',
|
||||
]));
|
||||
});
|
||||
|
||||
it('creates an empty default table without persistent records', () => {
|
||||
const model = new BaseDataModel({ id: 'base-1', name: 'Base' });
|
||||
const snapshot = model.getSnapshot();
|
||||
const table = snapshot.tables['table-1'];
|
||||
|
||||
expect(snapshot.tableOrder).toEqual(['table-1']);
|
||||
expect(table.recordOrder).toEqual([]);
|
||||
expect(table.records).toEqual({});
|
||||
expect(table.rowId).toEqual({});
|
||||
expect(table.rowIndex).toEqual({});
|
||||
expect(table.cellData).toEqual({});
|
||||
expect(table.fieldOrder[0]).toBe(BASE_RECORD_ID_FIELD_ID);
|
||||
expect(table.fields[BASE_RECORD_ID_FIELD_ID]).toEqual(createBaseRecordIdField());
|
||||
expect(table.views[table.viewOrder[0] ?? '']?.fieldSettings?.[BASE_RECORD_ID_FIELD_ID]?.hidden).toBe(true);
|
||||
});
|
||||
|
||||
it('keeps complete recordOrder without sorting all records', () => {
|
||||
const localeCompare = vi.spyOn(String.prototype, 'localeCompare').mockImplementation(() => {
|
||||
throw new Error('complete recordOrder should avoid sorting all records');
|
||||
});
|
||||
const snapshot: Partial<IBaseSnapshot> = {
|
||||
id: 'base-1',
|
||||
name: 'Base',
|
||||
tableOrder: ['table-1'],
|
||||
tables: {
|
||||
'table-1': {
|
||||
id: 'table-1',
|
||||
name: 'Table',
|
||||
fields: {
|
||||
[BASE_RECORD_ID_FIELD_ID]: createBaseRecordIdField(),
|
||||
title: { id: 'title', name: 'Title', type: BaseFieldType.Text, config: {} },
|
||||
},
|
||||
fieldOrder: [BASE_RECORD_ID_FIELD_ID, 'title'],
|
||||
primaryFieldId: 'title',
|
||||
records: {
|
||||
'record-1': {
|
||||
id: 'record-1',
|
||||
orderKey: '1',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
values: { [BASE_RECORD_ID_FIELD_ID]: 'record-1', title: 'One' },
|
||||
},
|
||||
'record-2': {
|
||||
id: 'record-2',
|
||||
orderKey: '2',
|
||||
createdAt: 0,
|
||||
updatedAt: 0,
|
||||
values: { [BASE_RECORD_ID_FIELD_ID]: 'record-2', title: 'Two' },
|
||||
},
|
||||
},
|
||||
recordOrder: ['record-2', 'record-1'],
|
||||
viewOrder: [],
|
||||
views: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const model = new BaseDataModel(snapshot);
|
||||
|
||||
expect(model.getSnapshot().tables['table-1'].recordOrder).toEqual(['record-2', 'record-1']);
|
||||
expect(model.getSnapshot().tables['table-1'].cellData?.[0]?.[0]?.v).toBe('record-2');
|
||||
expect(model.getSnapshot().tables['table-1'].cellData?.[1]?.[0]?.v).toBe('record-1');
|
||||
expect(localeCompare).not.toHaveBeenCalled();
|
||||
localeCompare.mockRestore();
|
||||
});
|
||||
|
||||
it('rejects a table whose record identity is missing instead of silently migrating it', () => {
|
||||
const snapshot: Partial<IBaseSnapshot> = {
|
||||
id: 'base-1',
|
||||
name: 'Base',
|
||||
tables: {
|
||||
'table-1': {
|
||||
id: 'table-1',
|
||||
name: 'Table',
|
||||
fields: {
|
||||
title: { id: 'title', name: 'Title', type: BaseFieldType.Text, config: {} },
|
||||
},
|
||||
fieldOrder: ['title'],
|
||||
primaryFieldId: 'title',
|
||||
records: {},
|
||||
recordOrder: [],
|
||||
viewOrder: [],
|
||||
views: {},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(() => new BaseDataModel(snapshot)).toThrow('has an invalid record-id system field');
|
||||
});
|
||||
});
|
||||
164
packages/core/src/bases/__tests__/formula-table-name.spec.ts
Normal file
164
packages/core/src/bases/__tests__/formula-table-name.spec.ts
Normal file
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { BaseDataModel } from '../base-data-model';
|
||||
import { getEmptySnapshot } from '../empty-snapshot';
|
||||
import {
|
||||
allocateBaseFormulaTableName,
|
||||
createBaseFormulaTableNameMap,
|
||||
createBaseFormulaTableReferenceNormalizer,
|
||||
getBaseFormulaTableName,
|
||||
migrateBaseFormulaTableNames,
|
||||
normalizeBaseFormulaTableName,
|
||||
normalizeBaseFormulaTableReferences,
|
||||
} from '../formula-table-name';
|
||||
|
||||
describe('Base formula table names', () => {
|
||||
it('normalizes display names into OOXML-compatible identifiers', () => {
|
||||
expect(normalizeBaseFormulaTableName('07 | Inventory and alerts')).toBe('_07_Inventory_and_alerts');
|
||||
expect(normalizeBaseFormulaTableName('A1')).toBe('_A1');
|
||||
expect(normalizeBaseFormulaTableName('R')).toBe('_R');
|
||||
expect(normalizeBaseFormulaTableName('库存')).toBe('Table');
|
||||
});
|
||||
|
||||
it('uses one deterministic namespace for normalized duplicates and suffix collisions', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
c: { id: 'c', name: 'Sales-table' },
|
||||
b: { id: 'b', name: 'Sales_table_2' },
|
||||
a: { id: 'a', name: 'Sales table' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.fromEntries(createBaseFormulaTableNameMap(snapshot))).toEqual({
|
||||
a: 'Sales_table',
|
||||
b: 'Sales_table_2',
|
||||
c: 'Sales_table_3',
|
||||
});
|
||||
expect(getBaseFormulaTableName(snapshot.tables.c, snapshot)).toBe('Sales_table_3');
|
||||
});
|
||||
|
||||
it('preserves persisted formula names when a lower-sorting colliding table is added', () => {
|
||||
const before = {
|
||||
tables: {
|
||||
b: { id: 'b', name: 'Sales table', formulaName: 'Sales_table' },
|
||||
},
|
||||
};
|
||||
const insertedFormulaName = allocateBaseFormulaTableName(
|
||||
'Sales-table',
|
||||
createBaseFormulaTableNameMap(before).values()
|
||||
);
|
||||
const after = {
|
||||
tables: {
|
||||
b: before.tables.b,
|
||||
a: { id: 'a', name: 'Sales-table', formulaName: insertedFormulaName },
|
||||
},
|
||||
};
|
||||
|
||||
expect(Object.fromEntries(createBaseFormulaTableNameMap(after))).toEqual({
|
||||
a: 'Sales_table_2',
|
||||
b: 'Sales_table',
|
||||
});
|
||||
expect(normalizeBaseFormulaTableReferences('=SUM(Sales_table[Amount])', after)).toBe(
|
||||
'=SUM(Sales_table[Amount])'
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the persisted formula name when the display name changes', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
a: { id: 'a', name: 'Revenue', formulaName: 'Sales' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(getBaseFormulaTableName(snapshot.tables.a, snapshot)).toBe('Sales');
|
||||
});
|
||||
|
||||
it('does not collapse a persisted suffix when another table is deleted', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
b: { id: 'b', name: 'Sales-table', formulaName: 'Sales_table_2' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(getBaseFormulaTableName(snapshot.tables.b, snapshot)).toBe('Sales_table_2');
|
||||
});
|
||||
|
||||
it('migrates id-derived input aliases without touching strings or external tables', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
'table-1': { id: 'table-1', name: 'Work Items' },
|
||||
},
|
||||
};
|
||||
|
||||
expect(normalizeBaseFormulaTableReferences(
|
||||
'=SUM(_T_table_x2d_1[Amount],table-1[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])',
|
||||
snapshot
|
||||
)).toBe('=SUM(Work_Items[Amount],Work_Items[Amount],"_T_table_x2d_1[Amount]",Book!_T_table_x2d_1[Amount])');
|
||||
});
|
||||
|
||||
it('reuses one compiled alias normalizer across formulas', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
'table.+(1)': { id: 'table.+(1)', name: 'Work Items' },
|
||||
},
|
||||
};
|
||||
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot);
|
||||
|
||||
expect(normalizeReferences('=SUM(table.+(1)[Amount])')).toBe('=SUM(Work_Items[Amount])');
|
||||
expect(normalizeReferences('="table.+(1)[Amount]"&Book!table.+(1)[Amount]')).toBe(
|
||||
'="table.+(1)[Amount]"&Book!table.+(1)[Amount]'
|
||||
);
|
||||
});
|
||||
|
||||
it('persists a canonical formula name while migrating a historical snapshot', () => {
|
||||
const snapshot = {
|
||||
tables: {
|
||||
'table-1': {
|
||||
id: 'table-1',
|
||||
name: 'Work Items',
|
||||
formulaName: undefined as string | undefined,
|
||||
fields: {
|
||||
total: { type: 'formula', config: { formula: '=SUM(table-1[Amount])' } },
|
||||
},
|
||||
cellData: {
|
||||
0: { 0: { f: '=SUM(_T_table_x2d_1[Amount])' } },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
migrateBaseFormulaTableNames(snapshot as never);
|
||||
|
||||
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
|
||||
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
|
||||
expect(snapshot.tables['table-1'].cellData[0][0].f).toBe('=SUM(Work_Items[Amount])');
|
||||
|
||||
migrateBaseFormulaTableNames(snapshot as never);
|
||||
expect(snapshot.tables['table-1'].formulaName).toBe('Work_Items');
|
||||
expect(snapshot.tables['table-1'].fields.total.config.formula).toBe('=SUM(Work_Items[Amount])');
|
||||
});
|
||||
|
||||
it('derives a historical formula name without silently changing the OT snapshot', () => {
|
||||
const snapshot = getEmptySnapshot('base-1', 'Base');
|
||||
snapshot.tables['table-1'].name = 'Work Items';
|
||||
const loaded = new BaseDataModel(snapshot).getSnapshot();
|
||||
|
||||
expect(loaded.tables['table-1'].formulaName).toBeUndefined();
|
||||
expect(getBaseFormulaTableName(loaded.tables['table-1'], loaded)).toBe('Work_Items');
|
||||
});
|
||||
});
|
||||
478
packages/core/src/bases/base-data-model.ts
Normal file
478
packages/core/src/bases/base-data-model.ts
Normal file
@@ -0,0 +1,478 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Observable } from 'rxjs';
|
||||
import type {
|
||||
CellValue,
|
||||
IBaseCellData,
|
||||
IBaseSnapshot,
|
||||
IFieldSnapshot,
|
||||
IRecordSnapshot,
|
||||
ITableSnapshot,
|
||||
} from './typedef';
|
||||
import { BehaviorSubject } from 'rxjs';
|
||||
import { UnitModel, UniverInstanceType } from '../common/unit';
|
||||
import { Tools } from '../shared/tools';
|
||||
import { CellValueType } from '../types/enum';
|
||||
import { getEmptySnapshot } from './empty-snapshot';
|
||||
import { assertBaseTableRecordIdentity, BASE_RECORD_ID_FIELD_ID } from './record-identity';
|
||||
import { BaseFieldType } from './typedef';
|
||||
|
||||
const BASE_LIST_VALUE_SEPARATOR = ', ';
|
||||
const BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR = '\u001F';
|
||||
type ICompressedBaseCellTuple = [number, number, IBaseCellData & { c?: 1 }];
|
||||
|
||||
export class BaseDataModel extends UnitModel<IBaseSnapshot, UniverInstanceType.UNIVER_BASE> {
|
||||
override readonly type: UniverInstanceType.UNIVER_BASE = UniverInstanceType.UNIVER_BASE;
|
||||
|
||||
private readonly _name$: BehaviorSubject<string>;
|
||||
override readonly name$: Observable<string>;
|
||||
private _snapshot: IBaseSnapshot;
|
||||
|
||||
constructor(snapshot: Partial<IBaseSnapshot> = {}) {
|
||||
super();
|
||||
|
||||
const defaultSnapshot = getEmptySnapshot();
|
||||
this._snapshot = normalizeBaseSnapshot(Tools.commonExtend(defaultSnapshot, snapshot));
|
||||
|
||||
if (!this._snapshot.id) {
|
||||
this._snapshot.id = `base-${Math.random().toString(36).slice(2, 10)}`;
|
||||
}
|
||||
|
||||
this._name$ = new BehaviorSubject<string>(this._snapshot.name);
|
||||
this.name$ = this._name$.asObservable();
|
||||
}
|
||||
|
||||
override getUnitId(): string {
|
||||
return this._snapshot.id;
|
||||
}
|
||||
|
||||
override setName(name: string): void {
|
||||
this._snapshot.name = name;
|
||||
this._snapshot.updatedAt = Date.now();
|
||||
this._name$.next(name);
|
||||
}
|
||||
|
||||
override getSnapshot(): IBaseSnapshot {
|
||||
return this._snapshot;
|
||||
}
|
||||
|
||||
/** Check if a table name already exists, ignoring case. */
|
||||
checkTableName(name: string): boolean {
|
||||
return Object.values(this._snapshot.tables).some((table) => table.name.toLowerCase() === name.toLowerCase());
|
||||
}
|
||||
|
||||
/** Generate a table name that does not conflict with existing tables. */
|
||||
uniqueTableName(name: string): string {
|
||||
let output = name;
|
||||
let count = 1;
|
||||
while (this.checkTableName(output)) {
|
||||
output = `${name}${count}`;
|
||||
count++;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
setSnapshot(snapshot: IBaseSnapshot): void {
|
||||
if (isNormalizedBaseSnapshot(snapshot)) {
|
||||
this._snapshot = snapshot;
|
||||
} else {
|
||||
this._snapshot = normalizeBaseSnapshot(snapshot);
|
||||
}
|
||||
this._name$.next(snapshot.name);
|
||||
}
|
||||
|
||||
override getRev(): number {
|
||||
return this._snapshot.rev ?? 1;
|
||||
}
|
||||
|
||||
override incrementRev(): void {
|
||||
this._snapshot.rev = this.getRev() + 1;
|
||||
}
|
||||
|
||||
override setRev(rev: number): void {
|
||||
this._snapshot.rev = rev;
|
||||
}
|
||||
|
||||
override dispose(): void {
|
||||
super.dispose();
|
||||
this._name$.complete();
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeBaseSnapshot(snapshot: IBaseSnapshot): IBaseSnapshot {
|
||||
delete (snapshot as IBaseSnapshot & { compress?: unknown }).compress;
|
||||
delete (snapshot as IBaseSnapshot & { kind?: unknown }).kind;
|
||||
Object.values(snapshot.tables ?? {}).forEach((table) => {
|
||||
assertBaseTableRecordIdentity(table);
|
||||
normalizeBaseTable(table);
|
||||
assertMaterializedRecordIdentity(table);
|
||||
});
|
||||
return snapshot;
|
||||
}
|
||||
|
||||
function isNormalizedBaseSnapshot(snapshot: IBaseSnapshot): boolean {
|
||||
return Object.values(snapshot.tables ?? {}).every((table) => isNormalizedBaseTable(table));
|
||||
}
|
||||
|
||||
function isNormalizedBaseTable(table: ITableSnapshot): boolean {
|
||||
const records = table.records ?? {};
|
||||
const fields = table.fields ?? {};
|
||||
assertBaseTableRecordIdentity(table);
|
||||
if (
|
||||
!Array.isArray(table.recordOrder)
|
||||
|| !table.rowIndex
|
||||
|| !table.rowId
|
||||
|| !table.colIndex
|
||||
|| !table.colId
|
||||
|| !table.cellData
|
||||
|| !table.resources
|
||||
|| !table.resources.attachmentSets
|
||||
|| !table.resources.attachments
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Object.keys(records).length !== table.recordOrder.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (let index = 0; index < table.recordOrder.length; index++) {
|
||||
const recordId = table.recordOrder[index];
|
||||
if (
|
||||
!records[recordId]
|
||||
|| table.rowIndex[recordId] !== index
|
||||
|| table.rowId[index] !== recordId
|
||||
|| table.cellData[index]?.[0]?.v !== recordId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
for (let index = 0; index < table.fieldOrder.length; index++) {
|
||||
const fieldId = table.fieldOrder[index];
|
||||
if (!fields[fieldId]) {
|
||||
continue;
|
||||
}
|
||||
if (table.colIndex[fieldId] !== index || table.colId[index] !== fieldId) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function normalizeBaseTable(table: ITableSnapshot): void {
|
||||
hydrateCompressedCellData(table);
|
||||
|
||||
const records = table.records ?? {};
|
||||
const fields = table.fields ?? {};
|
||||
const orderedRecordIds = getOrderedRecordIds(table, records);
|
||||
const orderedFieldIds = getOrderedFieldIds(table, fields);
|
||||
|
||||
table.recordOrder = orderedRecordIds;
|
||||
cloneBaseTableMutableMaps(table);
|
||||
normalizeExistingCellData(table, fields);
|
||||
rebuildRowIndexes(table, records, orderedRecordIds);
|
||||
rebuildColumnIndexes(table, fields, orderedFieldIds);
|
||||
hydrateRecordCellData(table, records, fields);
|
||||
}
|
||||
|
||||
function hydrateCompressedCellData(table: ITableSnapshot): void {
|
||||
const compressedCellData = (table as ITableSnapshot & { cd?: ICompressedBaseCellTuple[] }).cd;
|
||||
if (compressedCellData && !table.cellData) {
|
||||
table.cellData = Object.create(null) as ITableSnapshot['cellData'];
|
||||
compressedCellData.forEach(([row, col, cell]) => {
|
||||
table.cellData![row] = table.cellData![row] ?? {};
|
||||
const { c: _compressed, ...cellData } = cell;
|
||||
table.cellData![row][col] = cellData;
|
||||
});
|
||||
delete (table as ITableSnapshot & { cd?: unknown }).cd;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrderedRecordIds(table: ITableSnapshot, records: Record<string, IRecordSnapshot>): string[] {
|
||||
const orderedRecordIds = table.recordOrder?.filter((recordId) => records[recordId]) ?? [];
|
||||
const orderedRecordIdSet = new Set(orderedRecordIds);
|
||||
const missingRecords = Object.values(records).filter((record) => !orderedRecordIdSet.has(record.id));
|
||||
if (!missingRecords.length) {
|
||||
return orderedRecordIds;
|
||||
}
|
||||
|
||||
return [
|
||||
...orderedRecordIds,
|
||||
...missingRecords.sort((a, b) => a.orderKey.localeCompare(b.orderKey)).map((record) => record.id),
|
||||
];
|
||||
}
|
||||
|
||||
function getOrderedFieldIds(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>): string[] {
|
||||
return table.fieldOrder.filter((fieldId) => fields[fieldId]);
|
||||
}
|
||||
|
||||
function cloneBaseTableMutableMaps(table: ITableSnapshot): void {
|
||||
table.rowIndex = { ...table.rowIndex };
|
||||
table.rowId = { ...table.rowId };
|
||||
table.colIndex = { ...table.colIndex };
|
||||
table.colId = { ...table.colId };
|
||||
table.cellData = { ...table.cellData };
|
||||
table.resources = { ...table.resources };
|
||||
table.resources.attachmentSets = { ...table.resources.attachmentSets };
|
||||
table.resources.attachments = { ...table.resources.attachments };
|
||||
}
|
||||
|
||||
function normalizeExistingCellData(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>): void {
|
||||
Object.entries(table.cellData ?? {}).forEach(([rowKey, rowData]) => {
|
||||
const row = Number(rowKey);
|
||||
table.cellData![row] = { ...rowData };
|
||||
Object.entries(table.cellData![row] ?? {}).forEach(([colKey, cell]) => {
|
||||
const col = Number(colKey);
|
||||
const fieldId = table.colId?.[col];
|
||||
table.cellData![row][col] = normalizeBaseCellData(cell, fieldId ? fields[fieldId] : undefined);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildRowIndexes(table: ITableSnapshot, records: Record<string, IRecordSnapshot>, orderedRecordIds: string[]): void {
|
||||
orderedRecordIds.forEach((recordId, index) => {
|
||||
if (table.rowIndex![recordId] == null) {
|
||||
table.rowIndex![recordId] = index;
|
||||
}
|
||||
});
|
||||
Object.entries(table.rowIndex ?? {}).forEach(([recordId, row]) => {
|
||||
if (records[recordId]) {
|
||||
table.rowId![row] = recordId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function rebuildColumnIndexes(table: ITableSnapshot, fields: Record<string, IFieldSnapshot>, orderedFieldIds: string[]): void {
|
||||
orderedFieldIds.forEach((fieldId, index) => {
|
||||
if (table.colIndex![fieldId] == null) {
|
||||
table.colIndex![fieldId] = index;
|
||||
}
|
||||
});
|
||||
Object.entries(table.colIndex ?? {}).forEach(([fieldId, col]) => {
|
||||
if (fields[fieldId]) {
|
||||
table.colId![col] = fieldId;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function hydrateRecordCellData(table: ITableSnapshot, records: Record<string, IRecordSnapshot>, fields: Record<string, IFieldSnapshot>): void {
|
||||
const rowIndex = table.rowIndex ?? {};
|
||||
const colIndex = table.colIndex ?? {};
|
||||
const cellData = table.cellData ?? {};
|
||||
table.rowIndex = rowIndex;
|
||||
table.colIndex = colIndex;
|
||||
table.cellData = cellData;
|
||||
|
||||
Object.values(records).forEach((record) => {
|
||||
const row = rowIndex[record.id];
|
||||
if (row == null) {
|
||||
return;
|
||||
}
|
||||
cellData[row] = { ...cellData[row] };
|
||||
cellData[row][0] = { v: record.id, t: CellValueType.STRING };
|
||||
Object.entries(record.values ?? {}).forEach(([fieldId, value]) => {
|
||||
const field = fields[fieldId];
|
||||
if (field?.type === BaseFieldType.Attachment) {
|
||||
writeAttachmentResources(table, record.id, fieldId, value);
|
||||
}
|
||||
const col = colIndex[fieldId];
|
||||
if (col == null) {
|
||||
return;
|
||||
}
|
||||
const existingCell = cellData[row][col];
|
||||
if (existingCell != null) {
|
||||
cellData[row][col] = normalizeBaseCellData(existingCell, field);
|
||||
if (shouldRefreshCellDataFromRecord(existingCell, field, value)) {
|
||||
cellData[row][col] = toBaseCellData(value, field);
|
||||
}
|
||||
return;
|
||||
}
|
||||
cellData[row][col] = toBaseCellData(value, field);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function assertMaterializedRecordIdentity(table: ITableSnapshot): void {
|
||||
for (const record of Object.values(table.records)) {
|
||||
const row = table.rowIndex?.[record.id];
|
||||
if (
|
||||
row == null
|
||||
|| table.colIndex?.[BASE_RECORD_ID_FIELD_ID] !== 0
|
||||
|| table.colId?.[0] !== BASE_RECORD_ID_FIELD_ID
|
||||
|| table.cellData?.[row]?.[0]?.v !== record.id
|
||||
) {
|
||||
throw new Error(`[BaseDataModel]: record "${record.id}" is missing its record-id cell projection.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function toBaseCellData(value: CellValue | IBaseCellData, field?: IFieldSnapshot): IBaseCellData {
|
||||
if (isBaseCellData(value)) {
|
||||
if (field?.type === BaseFieldType.Attachment) {
|
||||
return { ...value, v: '', t: CellValueType.STRING };
|
||||
}
|
||||
return normalizeBaseCellData(value, field);
|
||||
}
|
||||
if (field?.type === BaseFieldType.Attachment) {
|
||||
return { v: '', t: CellValueType.STRING };
|
||||
}
|
||||
if (isListField(field)) {
|
||||
return { v: normalizeListValue(value).join(BASE_LIST_VALUE_SEPARATOR), t: CellValueType.STRING };
|
||||
}
|
||||
if (field?.type === BaseFieldType.Link && value && typeof value === 'object' && !Array.isArray(value)) {
|
||||
const link = value as { text?: unknown; url?: unknown };
|
||||
return { v: String(link.text ?? link.url ?? ''), t: CellValueType.STRING };
|
||||
}
|
||||
if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return { v: value, t: inferPrimitiveType(value) };
|
||||
}
|
||||
|
||||
return { v: null, t: null };
|
||||
}
|
||||
|
||||
function normalizeBaseCellData(cell: IBaseCellData, _field?: IFieldSnapshot): IBaseCellData {
|
||||
const type = normalizeBaseCellValueType(cell.t, cell.v);
|
||||
if (type === undefined) {
|
||||
const { t: _t, ...rest } = cell;
|
||||
return rest;
|
||||
}
|
||||
return { ...cell, t: type };
|
||||
}
|
||||
|
||||
function isBaseCellData(value: unknown): value is IBaseCellData {
|
||||
return !!value && typeof value === 'object' && (
|
||||
Object.prototype.hasOwnProperty.call(value, 'v')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 't')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'p')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'f')
|
||||
|| Object.prototype.hasOwnProperty.call(value, 'si')
|
||||
);
|
||||
}
|
||||
|
||||
function writeAttachmentResources(table: ITableSnapshot, recordId: string, fieldId: string, value: unknown): void {
|
||||
const attachments = normalizeAttachmentValue(value);
|
||||
table.resources = { ...table.resources };
|
||||
table.resources.attachmentSets = { ...table.resources.attachmentSets };
|
||||
table.resources.attachments = { ...table.resources.attachments };
|
||||
const key = `${fieldId}${BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR}${recordId}`;
|
||||
table.resources.attachmentSets[key] = attachments.map((attachment, index) => {
|
||||
const id = String(attachment.id ?? `${key}${BASE_ATTACHMENT_RESOURCE_KEY_SEPARATOR}${index}`);
|
||||
const normalizedAttachment = { ...attachment, id };
|
||||
table.resources!.attachments![id] = normalizedAttachment;
|
||||
return id;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeAttachmentValue(value: unknown): Record<string, unknown>[] {
|
||||
if (value == null || value === '') {
|
||||
return [];
|
||||
}
|
||||
if (isBaseCellData(value) && !('id' in value) && !('name' in value)) {
|
||||
return [];
|
||||
}
|
||||
const values = Array.isArray(value) ? value : [value];
|
||||
const attachments: Record<string, unknown>[] = [];
|
||||
for (let i = 0; i < values.length; i++) {
|
||||
const item = values[i];
|
||||
if (item && typeof item === 'object') {
|
||||
const attachment = { ...(item as Record<string, unknown>) };
|
||||
if (Object.keys(attachment).length > 0) {
|
||||
attachments.push(attachment);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
attachments.push({ id: `attachment-${i}`, name: String(item) });
|
||||
}
|
||||
return attachments;
|
||||
}
|
||||
|
||||
function shouldRefreshCellDataFromRecord(cell: IBaseCellData, field: IFieldSnapshot | undefined, value: unknown): boolean {
|
||||
if (field?.type === BaseFieldType.RecordId) {
|
||||
return cell.v !== value;
|
||||
}
|
||||
if (field?.type === BaseFieldType.Attachment) {
|
||||
return cell.v !== '';
|
||||
}
|
||||
if (isListField(field)) {
|
||||
return Array.isArray(value) && (cell.v == null || cell.v === '');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function isListField(field?: IFieldSnapshot): boolean {
|
||||
return field?.type === BaseFieldType.MultiSelect || field?.type === BaseFieldType.Person || field?.type === BaseFieldType.Group;
|
||||
}
|
||||
|
||||
function normalizeListValue(value: unknown): string[] {
|
||||
if (value == null || value === '') {
|
||||
return [];
|
||||
}
|
||||
const result: string[] = [];
|
||||
if (Array.isArray(value)) {
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
const item = primitiveText(value[i]);
|
||||
if (item) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
const items = String(value).split(',');
|
||||
for (let i = 0; i < items.length; i++) {
|
||||
const item = items[i].trim();
|
||||
if (item) {
|
||||
result.push(item);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function primitiveText(value: unknown): string {
|
||||
if (value == null) {
|
||||
return '';
|
||||
}
|
||||
if (typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') {
|
||||
return String(value);
|
||||
}
|
||||
if (typeof value === 'object') {
|
||||
const record = value as Record<string, unknown>;
|
||||
return String(record.id ?? record.name ?? record.text ?? '');
|
||||
}
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function inferPrimitiveType(value: CellValue): CellValueType | null {
|
||||
if (value == null) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
return CellValueType.NUMBER;
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return CellValueType.BOOLEAN;
|
||||
}
|
||||
return CellValueType.STRING;
|
||||
}
|
||||
|
||||
function normalizeBaseCellValueType(type: IBaseCellData['t'], value: IBaseCellData['v']): CellValueType | null | undefined {
|
||||
if (type === CellValueType.STRING || type === CellValueType.NUMBER || type === CellValueType.BOOLEAN || type === CellValueType.FORCE_STRING || type == null) {
|
||||
return type;
|
||||
}
|
||||
return inferPrimitiveType(value ?? null);
|
||||
}
|
||||
138
packages/core/src/bases/empty-snapshot.ts
Normal file
138
packages/core/src/bases/empty-snapshot.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IBaseSnapshot, IFieldSnapshot, IRecordSnapshot, ITableSnapshot, IViewSnapshot } from './typedef';
|
||||
import pkg from '../../package.json';
|
||||
import { generateRandomId } from '../shared';
|
||||
import { CellValueType, LocaleType } from '../types/enum';
|
||||
import { BASE_RECORD_ID_FIELD_ID, createBaseRecordIdField } from './record-identity';
|
||||
import { BaseFieldType, BaseViewType } from './typedef';
|
||||
|
||||
export interface ICreateDefaultBaseTableSnapshotOptions {
|
||||
id: string;
|
||||
name: string;
|
||||
primaryFieldId?: string;
|
||||
primaryFieldName?: string;
|
||||
gridViewId?: string;
|
||||
recordCount?: number;
|
||||
now?: number;
|
||||
}
|
||||
|
||||
export function createDefaultBaseTableSnapshot(options: ICreateDefaultBaseTableSnapshotOptions): ITableSnapshot {
|
||||
const now = options.now ?? Date.now();
|
||||
const primaryFieldId = options.primaryFieldId ?? generateRandomId(6);
|
||||
const gridViewId = options.gridViewId ?? generateRandomId(6);
|
||||
const recordCount = options.recordCount ?? 0;
|
||||
const recordIdField = createBaseRecordIdField();
|
||||
const primaryField: IFieldSnapshot = {
|
||||
id: primaryFieldId,
|
||||
name: options.primaryFieldName ?? 'Name',
|
||||
type: BaseFieldType.Text,
|
||||
config: { placeholder: 'bases.fieldConfig.textPlaceholder' },
|
||||
};
|
||||
const records: Record<string, IRecordSnapshot> = {};
|
||||
const recordOrder: string[] = [];
|
||||
const rowIndex: Record<string, number> = {};
|
||||
const rowId: Record<number, string> = {};
|
||||
const cellData: NonNullable<ITableSnapshot['cellData']> = {};
|
||||
|
||||
for (let index = 0; index < recordCount; index++) {
|
||||
const recordId = `${options.id}-record-${index + 1}`;
|
||||
records[recordId] = {
|
||||
id: recordId,
|
||||
values: { [BASE_RECORD_ID_FIELD_ID]: recordId },
|
||||
orderKey: String(index + 1).padStart(4, '0'),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
recordOrder.push(recordId);
|
||||
rowIndex[recordId] = index;
|
||||
rowId[index] = recordId;
|
||||
cellData[index] = {
|
||||
0: { v: recordId, t: CellValueType.STRING },
|
||||
};
|
||||
}
|
||||
|
||||
const gridView: IViewSnapshot = {
|
||||
id: gridViewId,
|
||||
tableId: options.id,
|
||||
name: 'Grid',
|
||||
type: BaseViewType.Grid,
|
||||
fieldOrder: [BASE_RECORD_ID_FIELD_ID, primaryFieldId],
|
||||
fieldSettings: {
|
||||
[BASE_RECORD_ID_FIELD_ID]: { hidden: true },
|
||||
},
|
||||
config: { frozenFieldCount: 1 },
|
||||
};
|
||||
|
||||
return {
|
||||
id: options.id,
|
||||
name: options.name,
|
||||
primaryFieldId,
|
||||
fieldOrder: [BASE_RECORD_ID_FIELD_ID, primaryFieldId],
|
||||
fields: {
|
||||
[BASE_RECORD_ID_FIELD_ID]: recordIdField,
|
||||
[primaryFieldId]: primaryField,
|
||||
},
|
||||
records,
|
||||
recordOrder,
|
||||
rowIndex,
|
||||
rowId,
|
||||
colIndex: {
|
||||
[BASE_RECORD_ID_FIELD_ID]: 0,
|
||||
[primaryFieldId]: 1,
|
||||
},
|
||||
colId: {
|
||||
0: BASE_RECORD_ID_FIELD_ID,
|
||||
1: primaryFieldId,
|
||||
},
|
||||
cellData,
|
||||
resources: {
|
||||
attachmentSets: {},
|
||||
attachments: {},
|
||||
},
|
||||
views: { [gridViewId]: gridView },
|
||||
viewOrder: [gridViewId],
|
||||
};
|
||||
}
|
||||
|
||||
export function getEmptySnapshot(
|
||||
unitId: string = generateRandomId(),
|
||||
name: string = '',
|
||||
locale: LocaleType = LocaleType.ZH_CN
|
||||
): IBaseSnapshot {
|
||||
const now = Date.now();
|
||||
const tableId = 'table-1';
|
||||
|
||||
return {
|
||||
id: unitId,
|
||||
name,
|
||||
locale,
|
||||
appVersion: pkg.version,
|
||||
schemaVersion: 2,
|
||||
tableOrder: [tableId],
|
||||
tables: {
|
||||
[tableId]: createDefaultBaseTableSnapshot({
|
||||
id: tableId,
|
||||
name: 'Table 1',
|
||||
now,
|
||||
recordCount: 0,
|
||||
}),
|
||||
},
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
}
|
||||
285
packages/core/src/bases/formula-table-name.ts
Normal file
285
packages/core/src/bases/formula-table-name.ts
Normal file
@@ -0,0 +1,285 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IBaseSnapshot, ITableSnapshot } from './typedef';
|
||||
|
||||
type BaseFormulaTable = Pick<ITableSnapshot, 'id' | 'name' | 'formulaName'>;
|
||||
|
||||
interface IBaseFormulaSnapshot {
|
||||
tables: Record<string, BaseFormulaTable>;
|
||||
}
|
||||
|
||||
interface ICompiledBaseFormulaTableAliases {
|
||||
formulaNameByAlias: ReadonlyMap<string, string>;
|
||||
pattern?: RegExp;
|
||||
}
|
||||
|
||||
export function normalizeBaseFormulaTableName(displayName: string): string {
|
||||
const replaced = displayName.trim().replace(/[^A-Za-z0-9_.]+/g, '_');
|
||||
const normalized = /[A-Za-z0-9]/.test(replaced) ? replaced : 'Table';
|
||||
const prefixed = /^[A-Za-z_]/.test(normalized) ? normalized : `_${normalized}`;
|
||||
const nonReference = /^[RC]$/i.test(prefixed)
|
||||
|| /^[A-Za-z]{1,3}[1-9]\d*$/.test(prefixed)
|
||||
|| /^R(?:\d+)?C(?:\d+)?$/i.test(prefixed)
|
||||
? `_${prefixed}`
|
||||
: prefixed;
|
||||
return nonReference.slice(0, 255);
|
||||
}
|
||||
|
||||
export function createBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const usedNames = new Set<string>();
|
||||
const nextSuffixByBaseName = new Map<string, number>();
|
||||
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
|
||||
|
||||
// Persisted names own the namespace. Missing historical names are allocated only
|
||||
// after every valid persisted name has been reserved, so loading an old table can
|
||||
// never steal the stable formula identity of a newer table.
|
||||
for (const table of tables) {
|
||||
const formulaName = validBaseFormulaTableName(table.formulaName);
|
||||
if (!formulaName || usedNames.has(formulaName.toLowerCase())) continue;
|
||||
usedNames.add(formulaName.toLowerCase());
|
||||
result.set(table.id, formulaName);
|
||||
}
|
||||
|
||||
for (const table of tables) {
|
||||
if (result.has(table.id)) continue;
|
||||
const formulaName = allocateBaseFormulaTableNameFromSet(
|
||||
table.name,
|
||||
usedNames,
|
||||
table.formulaName,
|
||||
nextSuffixByBaseName
|
||||
);
|
||||
usedNames.add(formulaName.toLowerCase());
|
||||
result.set(table.id, formulaName);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
export function allocateBaseFormulaTableName(
|
||||
displayName: string,
|
||||
existingNames: Iterable<string>,
|
||||
preferredName?: string
|
||||
): string {
|
||||
const usedNames = new Set(Array.from(existingNames, (name) => name.toLowerCase()));
|
||||
return allocateBaseFormulaTableNameFromSet(displayName, usedNames, preferredName);
|
||||
}
|
||||
|
||||
function allocateBaseFormulaTableNameFromSet(
|
||||
displayName: string,
|
||||
usedNames: ReadonlySet<string>,
|
||||
preferredName?: string,
|
||||
nextSuffixByBaseName = new Map<string, number>()
|
||||
): string {
|
||||
const validPreferredName = validBaseFormulaTableName(preferredName);
|
||||
if (validPreferredName && !usedNames.has(validPreferredName.toLowerCase())) {
|
||||
return validPreferredName;
|
||||
}
|
||||
|
||||
const baseName = normalizeBaseFormulaTableName(displayName);
|
||||
const baseNameKey = baseName.toLowerCase();
|
||||
if (!usedNames.has(baseNameKey)) {
|
||||
return baseName;
|
||||
}
|
||||
|
||||
let suffixNumber = nextSuffixByBaseName.get(baseNameKey) ?? 2;
|
||||
while (true) {
|
||||
const suffix = `_${suffixNumber++}`;
|
||||
const formulaName = `${baseName.slice(0, 255 - suffix.length)}${suffix}`;
|
||||
if (!usedNames.has(formulaName.toLowerCase())) {
|
||||
nextSuffixByBaseName.set(baseNameKey, suffixNumber);
|
||||
return formulaName;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function getBaseFormulaTableName(
|
||||
table: BaseFormulaTable,
|
||||
snapshot: IBaseFormulaSnapshot
|
||||
): string {
|
||||
return createBaseFormulaTableNameMap(snapshot).get(table.id) ?? normalizeBaseFormulaTableName(table.name);
|
||||
}
|
||||
|
||||
export function normalizeBaseFormulaTableReferences(formula: string, snapshot: IBaseFormulaSnapshot): string {
|
||||
return createBaseFormulaTableReferenceNormalizer(snapshot)(formula);
|
||||
}
|
||||
|
||||
export function createBaseFormulaTableReferenceNormalizer(
|
||||
snapshot: IBaseFormulaSnapshot,
|
||||
formulaNames = createBaseFormulaTableNameMap(snapshot)
|
||||
): (formula: string) => string {
|
||||
const compiledAliases = compileBaseFormulaTableAliases(createBaseFormulaTableAliases(snapshot, formulaNames));
|
||||
return (formula) => rewriteFormulaTableAliases(formula, compiledAliases);
|
||||
}
|
||||
|
||||
export function migrateBaseFormulaTableNames(snapshot: IBaseSnapshot): void {
|
||||
const formulaNames = createBaseFormulaTableNameMap(snapshot);
|
||||
const normalizeReferences = createBaseFormulaTableReferenceNormalizer(snapshot, formulaNames);
|
||||
for (const table of Object.values(snapshot.tables)) {
|
||||
table.formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
|
||||
}
|
||||
for (const table of Object.values(snapshot.tables)) {
|
||||
for (const field of Object.values(table.fields)) {
|
||||
if (field.type !== 'formula' || typeof field.config?.formula !== 'string') continue;
|
||||
field.config.formula = normalizeReferences(field.config.formula);
|
||||
}
|
||||
for (const row of Object.values(table.cellData ?? {})) {
|
||||
for (const cell of Object.values(row ?? {})) {
|
||||
if (cell && typeof cell.f === 'string') {
|
||||
cell.f = normalizeReferences(cell.f);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createBaseFormulaTableAliases(
|
||||
snapshot: IBaseFormulaSnapshot,
|
||||
formulaNames = createBaseFormulaTableNameMap(snapshot)
|
||||
): Array<{ alias: string; formulaName: string }> {
|
||||
const legacyFormulaNames = createLegacyBaseFormulaTableNameMap(snapshot);
|
||||
const formulaNameOwners = new Map(
|
||||
Array.from(formulaNames, ([tableId, formulaName]) => [formulaName.toLowerCase(), tableId])
|
||||
);
|
||||
const displayNameCounts = new Map<string, number>();
|
||||
for (const table of Object.values(snapshot.tables)) {
|
||||
const key = table.name.toLowerCase();
|
||||
displayNameCounts.set(key, (displayNameCounts.get(key) ?? 0) + 1);
|
||||
}
|
||||
return Object.values(snapshot.tables)
|
||||
.flatMap((table) => {
|
||||
const formulaName = formulaNames.get(table.id) ?? normalizeBaseFormulaTableName(table.name);
|
||||
const inputAliases = [
|
||||
table.id,
|
||||
createLegacyBaseFormulaTableName(table.id),
|
||||
legacyFormulaNames.get(table.id),
|
||||
table.formulaName,
|
||||
];
|
||||
if (displayNameCounts.get(table.name.toLowerCase()) === 1) {
|
||||
inputAliases.push(table.name);
|
||||
}
|
||||
return inputAliases
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.flatMap((alias) => [alias, quoteBaseFormulaTableAlias(alias)])
|
||||
.filter((alias): alias is string => Boolean(alias))
|
||||
.filter((alias) => alias.toLowerCase() !== formulaName.toLowerCase())
|
||||
.filter((alias) => {
|
||||
const owner = formulaNameOwners.get(unquoteBaseFormulaTableAlias(alias).toLowerCase());
|
||||
return owner == null || owner === table.id;
|
||||
})
|
||||
.map((alias) => ({ alias, formulaName }));
|
||||
})
|
||||
.sort((left, right) => right.alias.length - left.alias.length);
|
||||
}
|
||||
|
||||
function createLegacyBaseFormulaTableNameMap(snapshot: IBaseFormulaSnapshot): ReadonlyMap<string, string> {
|
||||
const result = new Map<string, string>();
|
||||
const usedNames = new Set<string>();
|
||||
const nextSuffixByBaseName = new Map<string, number>();
|
||||
const tables = Object.values(snapshot.tables).sort((left, right) => left.id.localeCompare(right.id));
|
||||
for (const table of tables) {
|
||||
const formulaName = allocateBaseFormulaTableNameFromSet(
|
||||
table.name,
|
||||
usedNames,
|
||||
undefined,
|
||||
nextSuffixByBaseName
|
||||
);
|
||||
usedNames.add(formulaName.toLowerCase());
|
||||
result.set(table.id, formulaName);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function validBaseFormulaTableName(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.trim();
|
||||
return trimmed && normalizeBaseFormulaTableName(trimmed) === trimmed ? trimmed : undefined;
|
||||
}
|
||||
|
||||
function quoteBaseFormulaTableAlias(alias: string): string | undefined {
|
||||
return /[^A-Za-z0-9_.]/.test(alias) ? `'${alias.replaceAll("'", "''")}'` : undefined;
|
||||
}
|
||||
|
||||
function unquoteBaseFormulaTableAlias(alias: string): string {
|
||||
return alias.startsWith("'") && alias.endsWith("'")
|
||||
? alias.slice(1, -1).replaceAll("''", "'")
|
||||
: alias;
|
||||
}
|
||||
|
||||
function compileBaseFormulaTableAliases(
|
||||
aliases: ReadonlyArray<{ alias: string; formulaName: string }>
|
||||
): ICompiledBaseFormulaTableAliases {
|
||||
const formulaNameByAlias = new Map<string, string>();
|
||||
for (const { alias, formulaName } of aliases) {
|
||||
const key = alias.toLowerCase();
|
||||
if (!formulaNameByAlias.has(key)) {
|
||||
formulaNameByAlias.set(key, formulaName);
|
||||
}
|
||||
}
|
||||
const orderedAliases = Array.from(formulaNameByAlias.keys()).sort((left, right) => right.length - left.length);
|
||||
return {
|
||||
formulaNameByAlias,
|
||||
pattern: orderedAliases.length ? new RegExp(`(${orderedAliases.map(escapeRegExp).join('|')})(\\s*)\\[`, 'gi') : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function createLegacyBaseFormulaTableName(tableId: string): string {
|
||||
const encoded = Array.from(tableId, (character) => /[A-Za-z0-9]/.test(character) ? character : `_x${character.codePointAt(0)?.toString(16) ?? '0'}_`).join('');
|
||||
return `_T_${encoded}`;
|
||||
}
|
||||
|
||||
function rewriteFormulaTableAliases(
|
||||
formula: string,
|
||||
aliases: ICompiledBaseFormulaTableAliases
|
||||
): string {
|
||||
if (!aliases.pattern) return formula;
|
||||
|
||||
let scannedUntil = 0;
|
||||
let inString = false;
|
||||
aliases.pattern.lastIndex = 0;
|
||||
return formula.replace(
|
||||
aliases.pattern,
|
||||
(match: string, alias: string, whitespace: string, offset: number) => {
|
||||
inString = scanFormulaStringState(formula, scannedUntil, offset, inString);
|
||||
scannedUntil = offset;
|
||||
const previous = formula[offset - 1];
|
||||
const hasIdentifierPrefix = previous != null && /[A-Za-z0-9_.]/.test(previous);
|
||||
const isExternalReference = previous === '!';
|
||||
if (hasIdentifierPrefix || isExternalReference || inString) {
|
||||
return match;
|
||||
}
|
||||
const formulaName = aliases.formulaNameByAlias.get(alias.toLowerCase());
|
||||
return formulaName ? `${formulaName}${whitespace}[` : match;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
function scanFormulaStringState(formula: string, start: number, end: number, initialState: boolean): boolean {
|
||||
let inString = initialState;
|
||||
for (let index = start; index < end; index++) {
|
||||
if (formula[index] !== '"') continue;
|
||||
if (inString && formula[index + 1] === '"') {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
inString = !inString;
|
||||
}
|
||||
return inString;
|
||||
}
|
||||
130
packages/core/src/bases/index.ts
Normal file
130
packages/core/src/bases/index.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { BaseDataModel } from './base-data-model';
|
||||
export {
|
||||
createDefaultBaseTableSnapshot,
|
||||
getEmptySnapshot as getBasesEmptySnapshot,
|
||||
type ICreateDefaultBaseTableSnapshotOptions,
|
||||
} from './empty-snapshot';
|
||||
export {
|
||||
allocateBaseFormulaTableName,
|
||||
createBaseFormulaTableNameMap,
|
||||
createBaseFormulaTableReferenceNormalizer,
|
||||
getBaseFormulaTableName,
|
||||
migrateBaseFormulaTableNames,
|
||||
normalizeBaseFormulaTableName,
|
||||
normalizeBaseFormulaTableReferences,
|
||||
} from './formula-table-name';
|
||||
export {
|
||||
assertBaseTableRecordIdentity,
|
||||
BASE_RECORD_ID_FIELD_ID,
|
||||
BASE_RECORD_ID_FIELD_NAME,
|
||||
createBaseRecordIdField,
|
||||
isBaseRecordIdFieldName,
|
||||
isValidBaseRecordId,
|
||||
} from './record-identity';
|
||||
export {
|
||||
BaseConditionalColorOperator,
|
||||
BaseConditionalColorTarget,
|
||||
BaseConditionalDateMode,
|
||||
BaseFieldType,
|
||||
BaseFilterConjunction,
|
||||
BaseFilterOperator,
|
||||
BaseHierarchyInvalidReason,
|
||||
BaseRecordLinkRole,
|
||||
BaseSortDirection,
|
||||
BaseViewType,
|
||||
} from './typedef';
|
||||
export type {
|
||||
BaseCellMatrix,
|
||||
BaseCellPrimitiveValue,
|
||||
CellValue as BaseCellValue,
|
||||
BaseCellValueType,
|
||||
BaseDateHourCycle,
|
||||
BaseHitTestResult,
|
||||
BaseId,
|
||||
PrimitiveCellValue as BasePrimitiveCellValue,
|
||||
BaseSelection,
|
||||
BaseSnapshot,
|
||||
BaseViewProjection,
|
||||
FieldConfig,
|
||||
FieldId,
|
||||
FieldSnapshot,
|
||||
IBaseAttachment,
|
||||
IBaseCellData,
|
||||
IBaseConditionalColoringConfig,
|
||||
IBaseConditionalColorRule,
|
||||
IBaseDateFieldConfig,
|
||||
IBaseHierarchyNodeProjection,
|
||||
IBaseHierarchyProjection,
|
||||
IBaseInvalidation,
|
||||
IBaseRect,
|
||||
IBaseResources,
|
||||
IBaseSnapshot,
|
||||
IBaseViewColorCondition,
|
||||
IBaseViewCommonConfig,
|
||||
IBaseViewport,
|
||||
ICalendarEventResizeSelection,
|
||||
ICalendarEventSelection,
|
||||
ICalendarProjection,
|
||||
ICalendarViewConfig,
|
||||
ICardLayoutConfig,
|
||||
IFieldCapabilities,
|
||||
IFieldSnapshot,
|
||||
IFilterCondition,
|
||||
IFilterConfig,
|
||||
IGalleryCardSelection,
|
||||
IGalleryProjection,
|
||||
IGalleryViewConfig,
|
||||
IGanttBarSelection,
|
||||
IGanttCellSelection,
|
||||
IGanttProjection,
|
||||
IGanttTimeColumn,
|
||||
IGanttViewConfig,
|
||||
IGridCellSelection,
|
||||
IGridFieldSelection,
|
||||
IGridGroupSelection,
|
||||
IGridProjection,
|
||||
IGridRecordSelection,
|
||||
IGridViewConfig,
|
||||
IGroupConfig,
|
||||
IInvalidViewProjection,
|
||||
IKanbanCardSelection,
|
||||
IKanbanColumnSetting,
|
||||
IKanbanFieldCardSetting,
|
||||
IKanbanProjection,
|
||||
IKanbanViewConfig,
|
||||
IProjectedField,
|
||||
IProjectedGroup,
|
||||
IProjectedRow,
|
||||
IRecordLinkFieldConfig,
|
||||
IRecordSnapshot,
|
||||
ISortConfig,
|
||||
ITableSnapshot,
|
||||
IValidationResult,
|
||||
IViewFieldSetting,
|
||||
IViewProjection,
|
||||
IViewSnapshot,
|
||||
KanbanCardLayoutMode,
|
||||
RecordId,
|
||||
RecordSnapshot,
|
||||
TableId,
|
||||
TableSnapshot,
|
||||
ViewId,
|
||||
ViewSnapshot,
|
||||
ViewSpecificConfig,
|
||||
} from './typedef';
|
||||
77
packages/core/src/bases/record-identity.ts
Normal file
77
packages/core/src/bases/record-identity.ts
Normal file
@@ -0,0 +1,77 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IFieldSnapshot, ITableSnapshot } from './typedef';
|
||||
import { BaseFieldType } from './typedef';
|
||||
|
||||
export const BASE_RECORD_ID_FIELD_ID = '__record_id';
|
||||
export const BASE_RECORD_ID_FIELD_NAME = 'record-id';
|
||||
|
||||
const BASE_RECORD_ID_PATTERN = /^[A-Za-z0-9_-]+$/;
|
||||
|
||||
export function createBaseRecordIdField(): IFieldSnapshot {
|
||||
return {
|
||||
id: BASE_RECORD_ID_FIELD_ID,
|
||||
name: BASE_RECORD_ID_FIELD_NAME,
|
||||
type: BaseFieldType.RecordId,
|
||||
config: {},
|
||||
system: true,
|
||||
readonly: true,
|
||||
};
|
||||
}
|
||||
|
||||
export function isValidBaseRecordId(recordId: string): boolean {
|
||||
return BASE_RECORD_ID_PATTERN.test(recordId);
|
||||
}
|
||||
|
||||
export function isBaseRecordIdFieldName(name: string): boolean {
|
||||
return name.trim().toLowerCase() === BASE_RECORD_ID_FIELD_NAME;
|
||||
}
|
||||
|
||||
export function assertBaseTableRecordIdentity(table: ITableSnapshot): void {
|
||||
const recordIdField = table.fields[BASE_RECORD_ID_FIELD_ID];
|
||||
const recordIdFields = Object.values(table.fields).filter((field) => field.type === BaseFieldType.RecordId);
|
||||
if (
|
||||
!recordIdField
|
||||
|| recordIdFields.length !== 1
|
||||
|| recordIdField.type !== BaseFieldType.RecordId
|
||||
|| recordIdField.name !== BASE_RECORD_ID_FIELD_NAME
|
||||
|| recordIdField.system !== true
|
||||
|| recordIdField.readonly !== true
|
||||
|| table.fieldOrder[0] !== BASE_RECORD_ID_FIELD_ID
|
||||
|| table.primaryFieldId === BASE_RECORD_ID_FIELD_ID
|
||||
|| !table.fields[table.primaryFieldId]
|
||||
) {
|
||||
throw new Error(`[BaseDataModel]: table "${table.id}" has an invalid record-id system field.`);
|
||||
}
|
||||
|
||||
const conflictingField = Object.values(table.fields).find((field) => {
|
||||
return field.id !== BASE_RECORD_ID_FIELD_ID && isBaseRecordIdFieldName(field.name);
|
||||
});
|
||||
if (conflictingField) {
|
||||
throw new Error(`[BaseDataModel]: field name "${conflictingField.name}" is reserved for the record-id system field.`);
|
||||
}
|
||||
|
||||
for (const [recordKey, record] of Object.entries(table.records)) {
|
||||
if (
|
||||
record.id !== recordKey
|
||||
|| !isValidBaseRecordId(record.id)
|
||||
|| record.values[BASE_RECORD_ID_FIELD_ID] !== record.id
|
||||
) {
|
||||
throw new Error(`[BaseDataModel]: record "${recordKey}" has an invalid record-id projection.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1051
packages/core/src/bases/typedef.ts
Normal file
1051
packages/core/src/bases/typedef.ts
Normal file
File diff suppressed because it is too large
Load Diff
83
packages/core/src/common/__tests__/array.spec.ts
Normal file
83
packages/core/src/common/__tests__/array.spec.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { dedupe, dedupeBy, findLast, groupBy, makeArray, remove, rotate } from '../array';
|
||||
|
||||
describe('test array utils', () => {
|
||||
it('should "rotate" function work correctly', () => {
|
||||
const raw = [1, 2, 3, 4, 5];
|
||||
|
||||
// The function do not mutate the original array.
|
||||
expect(rotate(raw, 0)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(rotate(raw, 1)).toEqual([2, 3, 4, 5, 1]);
|
||||
expect(rotate(raw, 2)).toEqual([3, 4, 5, 1, 2]);
|
||||
expect(rotate(raw, 3)).toEqual([4, 5, 1, 2, 3]);
|
||||
expect(rotate(raw, 4)).toEqual([5, 1, 2, 3, 4]);
|
||||
expect(rotate(raw, 5)).toEqual([1, 2, 3, 4, 5]);
|
||||
expect(rotate(raw, 6)).toEqual([2, 3, 4, 5, 1]);
|
||||
});
|
||||
|
||||
it('should "dedupeBy" function work correctly', () => {
|
||||
const raw = [
|
||||
{ id: '1', name: 'a' },
|
||||
{ id: '2', name: 'b' },
|
||||
{ id: '3', name: 'c' },
|
||||
{ id: '1', name: 'd' },
|
||||
];
|
||||
|
||||
expect(raw.length).toBe(4);
|
||||
expect(raw[0].name).toBe('a');
|
||||
expect(raw[1].name).toBe('b');
|
||||
expect(raw[2].name).toBe('c');
|
||||
expect(raw[3].name).toBe('d');
|
||||
|
||||
const deduped = dedupeBy(raw, (item) => item.id);
|
||||
expect(deduped.length).toBe(3);
|
||||
expect(deduped[0].name).toBe('a');
|
||||
expect(deduped[1].name).toBe('b');
|
||||
expect(deduped[2].name).toBe('c');
|
||||
expect(deduped[0].id).toBe('1');
|
||||
});
|
||||
|
||||
it('should remove, dedupe, findLast, groupBy and make arrays consistently', () => {
|
||||
const raw = [1, 2, 2, 3, 4, 3];
|
||||
|
||||
expect(remove(raw, 2)).toBe(true);
|
||||
expect(raw).toEqual([1, 2, 3, 4, 3]);
|
||||
expect(remove(raw, 9)).toBe(false);
|
||||
|
||||
expect(dedupe(raw)).toEqual([1, 2, 3, 4]);
|
||||
expect(findLast(raw, (item, index) => item % 2 === 1 && index > 2)).toBe(3);
|
||||
expect(findLast(raw, (item) => item === 9)).toBeNull();
|
||||
|
||||
const grouped = groupBy([
|
||||
{ group: 'odd', value: 1 },
|
||||
{ group: 'even', value: 2 },
|
||||
{ group: 'odd', value: 3 },
|
||||
], (item) => item.group);
|
||||
|
||||
expect(grouped.get('odd')).toEqual([
|
||||
{ group: 'odd', value: 1 },
|
||||
{ group: 'odd', value: 3 },
|
||||
]);
|
||||
expect(grouped.get('even')).toEqual([{ group: 'even', value: 2 }]);
|
||||
|
||||
expect(makeArray('single')).toEqual(['single']);
|
||||
const arr = ['already'];
|
||||
expect(makeArray(arr)).toBe(arr);
|
||||
});
|
||||
});
|
||||
43
packages/core/src/common/__tests__/drawing-order.spec.ts
Normal file
43
packages/core/src/common/__tests__/drawing-order.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { ArrangeTypeEnum } from '../../types/interfaces/i-drawing';
|
||||
import { getDrawingOrderIndex, normalizeDrawingOrderIndex } from '../drawing-order';
|
||||
|
||||
describe('drawing order', () => {
|
||||
it('resolves relative arrangement to a zero-based order index', () => {
|
||||
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.front)).toBe(3);
|
||||
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.forward)).toBe(2);
|
||||
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.backward)).toBe(0);
|
||||
expect(getDrawingOrderIndex(1, 4, ArrangeTypeEnum.back)).toBe(0);
|
||||
});
|
||||
|
||||
it('keeps relative arrangement inside the available order', () => {
|
||||
expect(getDrawingOrderIndex(3, 4, ArrangeTypeEnum.forward)).toBe(3);
|
||||
expect(getDrawingOrderIndex(0, 4, ArrangeTypeEnum.backward)).toBe(0);
|
||||
expect(getDrawingOrderIndex(0, 0, ArrangeTypeEnum.front)).toBe(0);
|
||||
});
|
||||
|
||||
it('normalizes absolute indexes to the available order', () => {
|
||||
expect(normalizeDrawingOrderIndex(-1, 3)).toBe(0);
|
||||
expect(normalizeDrawingOrderIndex(1.9, 3)).toBe(1);
|
||||
expect(normalizeDrawingOrderIndex(99, 3)).toBe(2);
|
||||
expect(normalizeDrawingOrderIndex(Number.POSITIVE_INFINITY, 3)).toBe(2);
|
||||
expect(normalizeDrawingOrderIndex(Number.NaN, 3)).toBe(0);
|
||||
expect(normalizeDrawingOrderIndex(1, 0)).toBe(0);
|
||||
});
|
||||
});
|
||||
49
packages/core/src/common/__tests__/function.spec.ts
Normal file
49
packages/core/src/common/__tests__/function.spec.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { throttle } from '../function';
|
||||
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
describe('throttle', () => {
|
||||
it('does not replay a delayed old value after a newer leading call', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const render = vi.fn();
|
||||
const update = throttle(render, 100);
|
||||
update('initial');
|
||||
vi.setSystemTime(1010);
|
||||
update('old');
|
||||
// A background tab can delay timers beyond the throttle interval.
|
||||
vi.setSystemTime(1200);
|
||||
update('latest');
|
||||
vi.runAllTimers();
|
||||
expect(render.mock.calls).toEqual([['initial'], ['latest']]);
|
||||
});
|
||||
|
||||
it('still delivers the last value of a burst', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(1000);
|
||||
const render = vi.fn();
|
||||
const update = throttle(render, 100);
|
||||
update('initial');
|
||||
update('middle');
|
||||
update('latest');
|
||||
vi.runAllTimers();
|
||||
expect(render.mock.calls).toEqual([['initial'], ['latest']]);
|
||||
});
|
||||
});
|
||||
64
packages/core/src/common/__tests__/interceptor.spec.ts
Normal file
64
packages/core/src/common/__tests__/interceptor.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { AsyncInterceptorManager, createAsyncInterceptorKey } from '../interceptor';
|
||||
|
||||
describe('Test interceptor', () => {
|
||||
it('should return "true" when each interceptor returns true ', async () => {
|
||||
const INTERCEPTOR_KEY = createAsyncInterceptorKey<boolean, boolean>('test');
|
||||
const interceptor = new AsyncInterceptorManager({ INTERCEPTOR_KEY });
|
||||
|
||||
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
|
||||
priority: 0,
|
||||
handler: async (value, context, next) => {
|
||||
return await next(value);
|
||||
},
|
||||
});
|
||||
|
||||
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
|
||||
priority: 10,
|
||||
handler: async (value, context, next) => {
|
||||
return await next(true);
|
||||
},
|
||||
});
|
||||
|
||||
const interceptorRes = await interceptor.fetchThroughAsyncInterceptors(INTERCEPTOR_KEY)(false, true);
|
||||
expect(interceptorRes).toBe(true);
|
||||
});
|
||||
|
||||
it('should return "false" when one of the interceptor returns false', async () => {
|
||||
const INTERCEPTOR_KEY = createAsyncInterceptorKey<boolean, boolean>('test');
|
||||
const interceptor = new AsyncInterceptorManager({ INTERCEPTOR_KEY });
|
||||
|
||||
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
|
||||
priority: 0,
|
||||
handler: async (value, context, next) => {
|
||||
return await next(value);
|
||||
},
|
||||
});
|
||||
|
||||
interceptor.interceptAsync(interceptor.getInterceptPoints().INTERCEPTOR_KEY, {
|
||||
priority: 10,
|
||||
handler: async (value, context, next) => {
|
||||
return await next(false);
|
||||
},
|
||||
});
|
||||
|
||||
const interceptorRes = await interceptor.fetchThroughAsyncInterceptors(INTERCEPTOR_KEY)(false, true);
|
||||
expect(interceptorRes).toBe(false);
|
||||
});
|
||||
});
|
||||
70
packages/core/src/common/__tests__/invert-color.spec.ts
Normal file
70
packages/core/src/common/__tests__/invert-color.spec.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { invertColorByHSL, stringToRgb } from '../invert-color/invert-hsl';
|
||||
import { invertColorByMatrix } from '../invert-color/invert-rgb';
|
||||
import { denormalizeRGBColor, normalizeRGBColor } from '../invert-color/utils';
|
||||
|
||||
function expectRgbColorInRange(color: [number, number, number]) {
|
||||
color.forEach((channel) => {
|
||||
expect(Number.isInteger(channel)).toBe(true);
|
||||
expect(channel).toBeGreaterThanOrEqual(0);
|
||||
expect(channel).toBeLessThanOrEqual(255);
|
||||
});
|
||||
}
|
||||
|
||||
describe('invert color helpers', () => {
|
||||
it('should normalize and denormalize rgb colors', () => {
|
||||
expect(normalizeRGBColor([0, 128, 255])).toEqual([0, 128 / 255, 1]);
|
||||
expect(denormalizeRGBColor([0, 0.5, 1])).toEqual([0, 128, 255]);
|
||||
});
|
||||
|
||||
it('should parse rgb strings and ignore trailing channels', () => {
|
||||
expect(stringToRgb('12,34,56')).toEqual([12, 34, 56]);
|
||||
expect(stringToRgb('12,34,56,0.5')).toEqual([12, 34, 56]);
|
||||
});
|
||||
|
||||
it('should invert colors with the rgb matrix implementation', () => {
|
||||
expect(invertColorByMatrix([0, 0, 0])).toEqual([255, 255, 255]);
|
||||
expect(invertColorByMatrix([255, 255, 255])).toEqual([0, 0, 0]);
|
||||
expect(invertColorByMatrix([255, 0, 0])).toEqual([255, 85, 85]);
|
||||
});
|
||||
|
||||
it('should invert grayscale colors with the hsl implementation', () => {
|
||||
expect(invertColorByHSL([0, 0, 0])).toEqual([255, 255, 255]);
|
||||
expect(invertColorByHSL([255, 255, 255])).toEqual([3, 3, 3]);
|
||||
expect(invertColorByHSL([128, 128, 128])).toEqual([127, 127, 127]);
|
||||
});
|
||||
|
||||
it('should invert chromatic colors through all hue branches', () => {
|
||||
const redDominantLowBlue = invertColorByHSL([255, 128, 0]);
|
||||
const redDominantHighBlue = invertColorByHSL([255, 0, 128]);
|
||||
const greenDominant = invertColorByHSL([0, 255, 0]);
|
||||
const blueDominant = invertColorByHSL([0, 0, 255]);
|
||||
const brightChromatic = invertColorByHSL([255, 200, 100]);
|
||||
|
||||
[redDominantLowBlue, redDominantHighBlue, greenDominant, blueDominant, brightChromatic].forEach((color) => {
|
||||
expectRgbColorInRange(color);
|
||||
});
|
||||
|
||||
expect(redDominantLowBlue).toHaveLength(3);
|
||||
expect(redDominantHighBlue).toHaveLength(3);
|
||||
expect(greenDominant).toHaveLength(3);
|
||||
expect(blueDominant).toHaveLength(3);
|
||||
expect(brightChromatic).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
76
packages/core/src/common/__tests__/regexp.spec.ts
Normal file
76
packages/core/src/common/__tests__/regexp.spec.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { regexp } from '../regexp';
|
||||
|
||||
describe('regexp utilities', () => {
|
||||
it('escapes regular expression syntax characters', () => {
|
||||
expect(regexp.escapeRegExp('a+b*c?.[test]')).toBe(String.raw`a\+b\*c\?\.\[test\]`);
|
||||
expect(new RegExp(regexp.escapeRegExp('a+b*c?.[test]')).test('a+b*c?.[test]')).toBe(true);
|
||||
});
|
||||
|
||||
it('converts wildcard text to an anchored regular expression', () => {
|
||||
const regex = regexp.createFromWildcard('file-??-*.ts');
|
||||
|
||||
expect(regex.test('file-ab-index.ts')).toBe(true);
|
||||
expect(regex.test('FILE-12-.ts')).toBe(true);
|
||||
expect(regex.test('prefix-file-ab-index.ts')).toBe(false);
|
||||
expect(regex.test('file-a-index.ts')).toBe(false);
|
||||
});
|
||||
|
||||
it('builds literal regexes from text containing regex syntax', () => {
|
||||
const regex = regexp.createLiteralRegExp('a.b+(test)?', 'g');
|
||||
|
||||
expect('a.b+(test)? aXb+(test)?'.replace(regex, 'hit')).toBe('hit aXb+(test)?');
|
||||
});
|
||||
|
||||
it('builds character sets with union, subtraction, and intersection', () => {
|
||||
const letters = regexp.charset(['a', 'f']).subtract('b', ['d', 'e']);
|
||||
const reg = letters.toRegExp();
|
||||
|
||||
expect(letters.toString()).toBe('[acf]');
|
||||
expect(reg.test('a')).toBe(true);
|
||||
expect(reg.test('b')).toBe(false);
|
||||
expect(reg.test('c')).toBe(true);
|
||||
expect(reg.test('d')).toBe(false);
|
||||
expect(reg.test('e')).toBe(false);
|
||||
expect(reg.test('f')).toBe(true);
|
||||
|
||||
const intersection = regexp.charset(['a', 'z']).intersect(['m', 'p']);
|
||||
expect(intersection.toString()).toBe('[m-p]');
|
||||
});
|
||||
|
||||
it('validates dynamic regexp flags before constructing regexes', () => {
|
||||
expect(regexp.charset(['a', 'z']).toRegExp('iu').flags).toBe('iu');
|
||||
expect(regexp.or('SUM', 'AVERAGE').toRegExp('i').test('sum')).toBe(true);
|
||||
|
||||
expect(() => regexp.charset(['a', 'z']).toRegExp('ii')).toThrow('Invalid regular expression flags');
|
||||
expect(() => regexp.charset(['a', 'z']).toRegExp('x')).toThrow('Invalid regular expression flags');
|
||||
expect(() => regexp.or('SUM').toRegExp('gg')).toThrow('Invalid regular expression flags');
|
||||
});
|
||||
|
||||
it('builds alternatives from strings and character sets', () => {
|
||||
const keyword = regexp.or('SUM', 'SUMIF', regexp.charset('A', 'B'));
|
||||
const reg = new RegExp(`^${keyword}$`);
|
||||
|
||||
expect(keyword.toString()).toBe('(?:[A-B]|SUM|SUMIF)');
|
||||
expect(reg.test('A')).toBe(true);
|
||||
expect(reg.test('SUM')).toBe(true);
|
||||
expect(reg.test('SUMIF')).toBe(true);
|
||||
expect(reg.test('AVERAGE')).toBe(false);
|
||||
});
|
||||
});
|
||||
51
packages/core/src/common/__tests__/registry.spec.ts
Normal file
51
packages/core/src/common/__tests__/registry.spec.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { Registry, RegistryAsMap } from '../registry';
|
||||
|
||||
describe('registry helpers', () => {
|
||||
it('should add unique items into Registry and delete existing ones', () => {
|
||||
const registry = Registry.create<string>();
|
||||
|
||||
registry.add('a');
|
||||
registry.add('a');
|
||||
registry.add('b');
|
||||
|
||||
expect(registry.getData()).toEqual(['a', 'b']);
|
||||
|
||||
registry.delete('a');
|
||||
expect(registry.getData()).toEqual(['b']);
|
||||
});
|
||||
|
||||
it('should add unique keyed items into RegistryAsMap and delete by key', () => {
|
||||
const registry = RegistryAsMap.create();
|
||||
|
||||
registry.add('a', { value: 1 });
|
||||
registry.add('a', { value: 2 });
|
||||
registry.add('b', { value: 3 });
|
||||
|
||||
expect(Array.from(registry.getData().entries())).toEqual([
|
||||
['a', { value: 1 }],
|
||||
['b', { value: 3 }],
|
||||
]);
|
||||
|
||||
registry.delete('a');
|
||||
expect(Array.from(registry.getData().entries())).toEqual([
|
||||
['b', { value: 3 }],
|
||||
]);
|
||||
});
|
||||
});
|
||||
133
packages/core/src/common/__tests__/shims.spec.ts
Normal file
133
packages/core/src/common/__tests__/shims.spec.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { installShims } from '../shims';
|
||||
|
||||
type RestorableDescriptor = PropertyDescriptor | undefined;
|
||||
|
||||
function restoreProperty(target: object, key: PropertyKey, descriptor: RestorableDescriptor) {
|
||||
if (descriptor) {
|
||||
Object.defineProperty(target, key, descriptor);
|
||||
} else {
|
||||
Reflect.deleteProperty(target, key);
|
||||
}
|
||||
}
|
||||
|
||||
function setPropertyUndefined(target: object, key: PropertyKey) {
|
||||
Object.defineProperty(target, key, {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
describe('installShims', () => {
|
||||
let requestIdleDescriptor: RestorableDescriptor;
|
||||
let cancelIdleDescriptor: RestorableDescriptor;
|
||||
let queueMicrotaskDescriptor: RestorableDescriptor;
|
||||
let findLastDescriptor: RestorableDescriptor;
|
||||
let findLastIndexDescriptor: RestorableDescriptor;
|
||||
let stringAtDescriptor: RestorableDescriptor;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
requestIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'requestIdleCallback');
|
||||
cancelIdleDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'cancelIdleCallback');
|
||||
queueMicrotaskDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'queueMicrotask');
|
||||
findLastDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLast');
|
||||
findLastIndexDescriptor = Object.getOwnPropertyDescriptor(Array.prototype, 'findLastIndex');
|
||||
stringAtDescriptor = Object.getOwnPropertyDescriptor(String.prototype, 'at');
|
||||
|
||||
Object.defineProperty(globalThis, 'requestIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'cancelIdleCallback', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
Object.defineProperty(globalThis, 'queueMicrotask', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value: undefined,
|
||||
});
|
||||
setPropertyUndefined(Array.prototype, 'findLast');
|
||||
setPropertyUndefined(Array.prototype, 'findLastIndex');
|
||||
setPropertyUndefined(String.prototype, 'at');
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
restoreProperty(globalThis, 'requestIdleCallback', requestIdleDescriptor);
|
||||
restoreProperty(globalThis, 'cancelIdleCallback', cancelIdleDescriptor);
|
||||
restoreProperty(globalThis, 'queueMicrotask', queueMicrotaskDescriptor);
|
||||
restoreProperty(Array.prototype, 'findLast', findLastDescriptor);
|
||||
restoreProperty(Array.prototype, 'findLastIndex', findLastIndexDescriptor);
|
||||
restoreProperty(String.prototype, 'at', stringAtDescriptor);
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('should install requestIdleCallback and allow cancellation', () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
installShims();
|
||||
|
||||
const canceledId = globalThis.requestIdleCallback?.(callback as never);
|
||||
globalThis.cancelIdleCallback?.(canceledId as number);
|
||||
vi.advanceTimersByTime(5);
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
globalThis.requestIdleCallback?.(callback as never);
|
||||
vi.advanceTimersByTime(5);
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
const idleDeadline = callback.mock.calls[0][0] as { didTimeout: boolean; timeRemaining: () => number };
|
||||
expect(idleDeadline.didTimeout).toBe(false);
|
||||
expect(idleDeadline.timeRemaining()).toBeGreaterThanOrEqual(0);
|
||||
});
|
||||
|
||||
it('should install queueMicrotask polyfill', async () => {
|
||||
const callback = vi.fn();
|
||||
|
||||
installShims();
|
||||
globalThis.queueMicrotask?.(callback);
|
||||
|
||||
expect(callback).not.toHaveBeenCalled();
|
||||
|
||||
await Promise.resolve();
|
||||
|
||||
expect(callback).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should install array findLastIndex and findLast polyfills', () => {
|
||||
installShims();
|
||||
|
||||
expect([1, 2, 3, 2].findLastIndex((value) => value === 2)).toBe(3);
|
||||
expect([1, 2, 3, 2].findLast((value) => value === 2)).toBe(2);
|
||||
expect(() => Array.prototype.findLastIndex.call([1], null as never)).toThrow(/callback must be a function/);
|
||||
});
|
||||
|
||||
it('should install string at polyfill', () => {
|
||||
installShims();
|
||||
|
||||
expect('abcd'.at(1)).toBe('b');
|
||||
expect('abcd'.at(-1)).toBe('d');
|
||||
expect('abcd'.at(10)).toBeUndefined();
|
||||
});
|
||||
});
|
||||
72
packages/core/src/common/__tests__/url.spec.ts
Normal file
72
packages/core/src/common/__tests__/url.spec.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { isLegalUrl, isSafeUrl, normalizeUrl, resolveWithBasePath } from '../url';
|
||||
|
||||
describe('Test url utils', () => {
|
||||
it('should return true on legal url', () => {
|
||||
expect(isLegalUrl('https://univer.ai/')).toBeTruthy();
|
||||
expect(isLegalUrl('univer.ai')).toBeTruthy();
|
||||
expect(isLegalUrl('http://localhost:5173')).toBeTruthy();
|
||||
});
|
||||
|
||||
it('should return false on illegal url', () => {
|
||||
expect(isLegalUrl('https://univer')).toBeFalsy();
|
||||
expect(isLegalUrl('univer')).toBeFalsy();
|
||||
expect(isLegalUrl('192.168')).toBeFalsy();
|
||||
expect(isLegalUrl('咋看手机看到.是多少')).toBeFalsy();
|
||||
expect(isLegalUrl('univer.univer')).toBeFalsy();
|
||||
});
|
||||
|
||||
it('should add protocol to no protocol url', () => {
|
||||
expect(normalizeUrl('univer.ai')).toEqual('https://univer.ai');
|
||||
expect(normalizeUrl('https://univer.ai')).toEqual('https://univer.ai');
|
||||
expect(normalizeUrl('zhang@univer.ai')).toEqual('mailto://zhang@univer.ai');
|
||||
});
|
||||
|
||||
it('should allow safe url schemes and reject dangerous ones', () => {
|
||||
expect(isSafeUrl('https://univer.ai')).toBe(true);
|
||||
expect(isSafeUrl('http://localhost:3000')).toBe(true);
|
||||
expect(isSafeUrl('mailto:test@example.com')).toBe(true);
|
||||
expect(isSafeUrl('javascript:alert(1)')).toBe(false);
|
||||
expect(isSafeUrl('data:text/html,<script>alert(1)</script>')).toBe(false);
|
||||
expect(isSafeUrl('vbscript:msgbox(1)')).toBe(false);
|
||||
expect(isSafeUrl('')).toBe(false);
|
||||
});
|
||||
|
||||
it('should resolve url with base path', () => {
|
||||
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
|
||||
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
|
||||
);
|
||||
|
||||
expect(resolveWithBasePath('universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
|
||||
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
|
||||
);
|
||||
|
||||
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/univer')).toEqual(
|
||||
'http://univer.examples.com.cn:9080/univer/universer-api/strean/file/dowload?file_id-2008162633680711718'
|
||||
);
|
||||
|
||||
expect(resolveWithBasePath('https://univer.examples.com.cn/file/2007806045355209235/univer-New%20Sheet.xlsx', 'http://univer.examples.com.cn:9080/univer/')).toEqual(
|
||||
'https://univer.examples.com.cn/file/2007806045355209235/univer-New%20Sheet.xlsx'
|
||||
);
|
||||
|
||||
expect(resolveWithBasePath('/universer-api/strean/file/dowload?file_id-2008162633680711718', 'http://univer.examples.com.cn:9080/')).toEqual(
|
||||
'http://univer.examples.com.cn:9080/universer-api/strean/file/dowload?file_id-2008162633680711718'
|
||||
);
|
||||
});
|
||||
});
|
||||
141
packages/core/src/common/__tests__/utility-primitives.spec.ts
Normal file
141
packages/core/src/common/__tests__/utility-primitives.spec.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
import { isBooleanString } from '../boolean';
|
||||
import { noop, throttle } from '../function';
|
||||
import { mixinClass } from '../mixin';
|
||||
import { isNumeric, isSafeNumeric, willLoseNumericPrecision } from '../number';
|
||||
import { requestImmediateMacroTask } from '../request-immediate-macro-task';
|
||||
import { mergeSets } from '../set';
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe('common utility primitives', () => {
|
||||
it('should classify boolean strings case-insensitively', () => {
|
||||
expect(isBooleanString('true')).toBe(true);
|
||||
expect(isBooleanString('FALSE')).toBe(true);
|
||||
expect(isBooleanString('yes')).toBe(false);
|
||||
});
|
||||
|
||||
it('should keep noop side-effect free and throttle rapid invocations', () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
|
||||
|
||||
const calls: Array<[number, number]> = [];
|
||||
const context = {
|
||||
base: 7,
|
||||
throttled: throttle(function (this: { base: number }, step: number) {
|
||||
calls.push([this.base, step]);
|
||||
}, 16),
|
||||
};
|
||||
|
||||
expect(noop()).toBeUndefined();
|
||||
|
||||
context.throttled(1);
|
||||
expect(calls).toEqual([[7, 1]]);
|
||||
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.005Z'));
|
||||
context.throttled(2);
|
||||
vi.setSystemTime(new Date('2026-01-01T00:00:00.010Z'));
|
||||
context.throttled(3);
|
||||
expect(calls).toEqual([[7, 1]]);
|
||||
|
||||
vi.advanceTimersByTime(16);
|
||||
expect(calls).toEqual([[7, 1], [7, 3]]);
|
||||
});
|
||||
|
||||
it('should mix enumerable methods into a class prototype', () => {
|
||||
class Greeter {
|
||||
prefix = 'hi';
|
||||
}
|
||||
|
||||
mixinClass(Greeter.prototype, {
|
||||
greet(this: Greeter, name: string) {
|
||||
return `${this.prefix}, ${name}`;
|
||||
},
|
||||
});
|
||||
|
||||
const greeter = new Greeter() as Greeter & { greet(name: string): string };
|
||||
expect(greeter.greet('univer')).toBe('hi, univer');
|
||||
});
|
||||
|
||||
it('should recognize numeric strings and precision boundaries', () => {
|
||||
expect(isNumeric('-12.5')).toBe(true);
|
||||
expect(isNumeric('12e3')).toBe(false);
|
||||
|
||||
expect(isSafeNumeric(`${Number.MAX_SAFE_INTEGER}`)).toBe(true);
|
||||
expect(isSafeNumeric('9007199254740992')).toBe(false);
|
||||
|
||||
expect(willLoseNumericPrecision('123456789123456789')).toBe(true);
|
||||
expect(willLoseNumericPrecision('12345')).toBe(false);
|
||||
});
|
||||
|
||||
it('should merge sets into the first instance', () => {
|
||||
const left = new Set(['alpha', 'beta']);
|
||||
const right = new Set(['beta', 'gamma']);
|
||||
|
||||
const merged = mergeSets(left, right);
|
||||
|
||||
expect(merged).toBe(left);
|
||||
expect(Array.from(merged)).toEqual(['alpha', 'beta', 'gamma']);
|
||||
});
|
||||
|
||||
it('should schedule immediate macro tasks and allow cancellation', async () => {
|
||||
const events: string[] = [];
|
||||
const port1Close = vi.fn();
|
||||
const port2Close = vi.fn();
|
||||
vi.stubGlobal('MessageChannel', class {
|
||||
port1 = {
|
||||
onmessage: null as null | (() => void),
|
||||
close: port1Close,
|
||||
};
|
||||
|
||||
port2 = {
|
||||
postMessage: () => queueMicrotask(() => this.port1.onmessage?.()),
|
||||
close: port2Close,
|
||||
};
|
||||
});
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
requestImmediateMacroTask(() => {
|
||||
events.push('ran');
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
expect(events).toEqual(['ran']);
|
||||
expect(port1Close).toHaveBeenCalledOnce();
|
||||
expect(port2Close).toHaveBeenCalledOnce();
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
const cancel = requestImmediateMacroTask(() => {
|
||||
events.push('cancelled');
|
||||
});
|
||||
|
||||
cancel();
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
|
||||
expect(events).toEqual(['ran']);
|
||||
expect(port1Close).toHaveBeenCalledTimes(2);
|
||||
expect(port2Close).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
});
|
||||
104
packages/core/src/common/array.ts
Normal file
104
packages/core/src/common/array.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function remove<T>(arr: T[], item: T): boolean {
|
||||
const index = arr.indexOf(item);
|
||||
if (index > -1) {
|
||||
arr.splice(index, 1);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deduplicate an array.
|
||||
* @param arr The array to be dedupe.
|
||||
* @returns Return the deduplicated array.
|
||||
*/
|
||||
export function dedupe<T>(arr: T[]): T[] {
|
||||
const deduplicated = new Set<T>();
|
||||
const result: T[] = [];
|
||||
for (const element of arr) {
|
||||
if (!deduplicated.has(element)) {
|
||||
deduplicated.add(element);
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function dedupeBy<T>(arr: T[], keyFn: (v: T) => string): T[] {
|
||||
const deduplicated = new Set<string>();
|
||||
const result: T[] = [];
|
||||
for (const element of arr) {
|
||||
const key = keyFn(element);
|
||||
if (!deduplicated.has(key)) {
|
||||
deduplicated.add(key);
|
||||
result.push(element);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function findLast<T>(arr: T[], callback: (item: T, index: number) => boolean): T | null {
|
||||
for (let i = arr.length - 1; i > -1; i--) {
|
||||
const item = arr[i];
|
||||
if (callback(item, i)) {
|
||||
return item;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Rotate an array without mutating the original array.
|
||||
* @param arr the array to be rotated
|
||||
* @param steps how many steps to rotate
|
||||
* @returns the rotated array, it is another array, the original array is not mutated.
|
||||
*/
|
||||
export function rotate<T>(arr: Readonly<T[]>, steps: number): readonly T[] {
|
||||
if (arr.length === 0) {
|
||||
return arr;
|
||||
}
|
||||
|
||||
const offset = steps % arr.length;
|
||||
return arr.slice(offset).concat(arr.slice(0, offset));
|
||||
}
|
||||
|
||||
export function groupBy<T>(arr: Readonly<T[]>, keyFn: (v: T) => string): Map<string, T[]> {
|
||||
const groups = new Map<string, T[]>();
|
||||
|
||||
arr.forEach((element) => {
|
||||
const key = keyFn(element);
|
||||
|
||||
let group = groups.get(key);
|
||||
if (!groups.has(key)) {
|
||||
group = [];
|
||||
groups.set(key, group);
|
||||
}
|
||||
|
||||
group!.push(element);
|
||||
});
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
export function makeArray<T>(thing: T | T[]): T[] {
|
||||
if (Array.isArray(thing)) return thing;
|
||||
|
||||
return [thing];
|
||||
}
|
||||
19
packages/core/src/common/async.ts
Normal file
19
packages/core/src/common/async.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import AsyncLock from 'async-lock';
|
||||
|
||||
export { AsyncLock };
|
||||
20
packages/core/src/common/boolean.ts
Normal file
20
packages/core/src/common/boolean.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const BooleanStringSet = new Set(['true', 'false']);
|
||||
export function isBooleanString(str: string): boolean {
|
||||
return BooleanStringSet.has(str.toLowerCase());
|
||||
}
|
||||
41
packages/core/src/common/const.ts
Normal file
41
packages/core/src/common/const.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const PREFIX = '__INTERNAL_EDITOR__';
|
||||
|
||||
export const DOCS_NORMAL_EDITOR_UNIT_ID_KEY = `${PREFIX}DOCS_NORMAL`;
|
||||
|
||||
export const DOCS_FORMULA_BAR_EDITOR_UNIT_ID_KEY = `${PREFIX}DOCS_FORMULA_BAR`;
|
||||
|
||||
export const DOCS_COMMENT_EDITOR_UNIT_ID_KEY = `${PREFIX}COMMENT_EDITOR`;
|
||||
|
||||
export const DEFAULT_EMPTY_DOCUMENT_VALUE = '\r\n';
|
||||
|
||||
export const IS_ROW_STYLE_PRECEDE_COLUMN_STYLE = 'isRowStylePrecedeColumnStyle';
|
||||
|
||||
export const AUTO_HEIGHT_FOR_MERGED_CELLS = Symbol('AUTO_HEIGHT_FOR_MERGED_CELLS');
|
||||
|
||||
export function createInternalEditorID(id: string) {
|
||||
return `${PREFIX}${id}`;
|
||||
}
|
||||
|
||||
export function isInternalEditorID(id: string) {
|
||||
return id.startsWith(PREFIX);
|
||||
}
|
||||
|
||||
export function isCommentEditorID(id: string) {
|
||||
return id.startsWith(DOCS_COMMENT_EDITOR_UNIT_ID_KEY);
|
||||
}
|
||||
79
packages/core/src/common/di.ts
Normal file
79
packages/core/src/common/di.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Dependency, DependencyIdentifier, Injector } from '@wendellhu/redi';
|
||||
|
||||
export {
|
||||
createIdentifier,
|
||||
forwardRef,
|
||||
Inject,
|
||||
Injector,
|
||||
InjectSelf,
|
||||
isAsyncDependencyItem,
|
||||
isAsyncHook,
|
||||
isClassDependencyItem,
|
||||
isCtor,
|
||||
isDisposable,
|
||||
isFactoryDependencyItem,
|
||||
isValueDependencyItem,
|
||||
LookUp,
|
||||
Many,
|
||||
Optional,
|
||||
Quantity,
|
||||
RediError,
|
||||
Self,
|
||||
setDependencies,
|
||||
SkipSelf,
|
||||
WithNew,
|
||||
} from '@wendellhu/redi';
|
||||
export type {
|
||||
AsyncDependencyItem,
|
||||
AsyncHook,
|
||||
ClassDependencyItem,
|
||||
Ctor,
|
||||
Dependency,
|
||||
DependencyIdentifier,
|
||||
DependencyItem,
|
||||
DependencyPair,
|
||||
FactoryDependencyItem,
|
||||
IAccessor,
|
||||
IdentifierDecorator,
|
||||
IDisposable,
|
||||
SyncDependencyItem,
|
||||
ValueDependencyItem,
|
||||
} from '@wendellhu/redi';
|
||||
|
||||
/**
|
||||
* Register the dependencies to the injector.
|
||||
* @param injector The injector to register the dependencies.
|
||||
* @param dependencies The dependencies to register.
|
||||
*/
|
||||
export function registerDependencies(injector: Injector, dependencies: Dependency[]): void {
|
||||
dependencies.forEach((d) => injector.add(d));
|
||||
}
|
||||
|
||||
/**
|
||||
* Touch a group of dependencies to ensure they are instantiated.
|
||||
* @param injector The injector to touch the dependencies.
|
||||
* @param dependencies The dependencies to touch.
|
||||
*/
|
||||
export function touchDependencies(injector: Injector, dependencies: [DependencyIdentifier<unknown>][]): void {
|
||||
dependencies.forEach(([d]) => {
|
||||
if (injector.has(d)) {
|
||||
injector.get(d);
|
||||
}
|
||||
});
|
||||
}
|
||||
32
packages/core/src/common/drawing-order.ts
Normal file
32
packages/core/src/common/drawing-order.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { ArrangeTypeEnum } from '../types/interfaces/i-drawing';
|
||||
|
||||
/** Clamps a requested zero-based index to an available drawing order. */
|
||||
export function normalizeDrawingOrderIndex(index: number, length: number): number {
|
||||
const lastIndex = Math.max(0, length - 1);
|
||||
return Math.max(0, Math.min(Math.floor(index), lastIndex)) || 0;
|
||||
}
|
||||
|
||||
/** Resolves a relative drawing arrangement to its zero-based target index. */
|
||||
export function getDrawingOrderIndex(currentIndex: number, length: number, arrangeType: ArrangeTypeEnum): number {
|
||||
const index = normalizeDrawingOrderIndex(currentIndex, length);
|
||||
if (arrangeType === ArrangeTypeEnum.front) return normalizeDrawingOrderIndex(length - 1, length);
|
||||
if (arrangeType === ArrangeTypeEnum.forward) return normalizeDrawingOrderIndex(index + 1, length);
|
||||
if (arrangeType === ArrangeTypeEnum.backward) return normalizeDrawingOrderIndex(index - 1, length);
|
||||
return 0;
|
||||
}
|
||||
51
packages/core/src/common/equal.ts
Normal file
51
packages/core/src/common/equal.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function shallowEqual(objA: any, objB: any) {
|
||||
if (Object.is(objA, objB)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (typeof objA !== 'object' || !objA || typeof objB !== 'object' || !objB) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const keysA = Object.keys(objA);
|
||||
const keysB = Object.keys(objB);
|
||||
|
||||
if (keysA.length !== keysB.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const bHasOwnProperty = Object.prototype.hasOwnProperty.bind(objB);
|
||||
|
||||
// Test for A's keys different from B.
|
||||
for (let idx = 0; idx < keysA.length; idx++) {
|
||||
const key = keysA[idx];
|
||||
|
||||
if (!bHasOwnProperty(key)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const valueA = objA[key];
|
||||
const valueB = objB[key];
|
||||
if (valueA !== valueB) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
29
packages/core/src/common/error.ts
Normal file
29
packages/core/src/common/error.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class CustomCommandExecutionError extends Error {
|
||||
constructor(message: string) {
|
||||
super(message);
|
||||
this.name = 'CustomCommandExecutionError';
|
||||
}
|
||||
}
|
||||
|
||||
export class CanceledError extends CustomCommandExecutionError {
|
||||
constructor() {
|
||||
super('Canceled by facade');
|
||||
this.name = 'CanceledError';
|
||||
}
|
||||
}
|
||||
47
packages/core/src/common/function.ts
Normal file
47
packages/core/src/common/function.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* A no-op (no operation) function that does nothing.
|
||||
* Use this as a default placeholder for callbacks or optional handlers.
|
||||
*/
|
||||
export function noop(): void {}
|
||||
|
||||
export function throttle<T extends (...args: any[]) => any>(fn: T, wait: number = 16): T {
|
||||
let lastTime = 0;
|
||||
let timer: number | null = null;
|
||||
|
||||
return function throttled(this: any, ...args: any[]) {
|
||||
const now = Date.now();
|
||||
if (now - lastTime < wait) {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
timer = setTimeout(() => {
|
||||
lastTime = now;
|
||||
fn.apply(this, args);
|
||||
}, wait) as unknown as number;
|
||||
} else {
|
||||
if (timer) {
|
||||
clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
lastTime = now;
|
||||
fn.apply(this, args);
|
||||
}
|
||||
} as unknown as T;
|
||||
}
|
||||
250
packages/core/src/common/interceptor.ts
Normal file
250
packages/core/src/common/interceptor.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/* eslint-disable ts/no-explicit-any */
|
||||
|
||||
import type { Nullable } from '../shared/types';
|
||||
import { remove } from './array';
|
||||
|
||||
export type InterceptorHandler<M = unknown, C = unknown> = (
|
||||
value: Nullable<M>,
|
||||
context: C,
|
||||
next: (value: Nullable<M>) => Nullable<M>
|
||||
) => Nullable<M>;
|
||||
|
||||
export enum InterceptorEffectEnum {
|
||||
Style = 1, // 1<< 0
|
||||
Value = 2, // 1<< 1
|
||||
}
|
||||
|
||||
export interface IInterceptor<M, C> {
|
||||
id?: string;
|
||||
priority?: number;
|
||||
handler: InterceptorHandler<M, C>;
|
||||
}
|
||||
|
||||
export interface ICellInterceptor<M, C> extends IInterceptor<M, C> {
|
||||
/**
|
||||
* The effect of the interceptor handler.
|
||||
* If the effect is 'Style', the worksheet@getCellValueOnly will bypass this interceptor.
|
||||
* If the effect is 'Value', the worksheet@getStyleOnly will bypass this interceptor.
|
||||
*/
|
||||
effect?: InterceptorEffectEnum;
|
||||
}
|
||||
|
||||
export function createInterceptorKey<T, C>(key: string): IInterceptor<T, C> {
|
||||
const symbol = `sheet_interceptor_${key}`;
|
||||
return symbol as unknown as IInterceptor<T, C>; // FIXME: priority and handler is completely missing?
|
||||
};
|
||||
|
||||
export type IComposeInterceptors<T = any, C = any> = (
|
||||
interceptors: Array<IInterceptor<T, C>>
|
||||
) => (initValue: Nullable<T>, initContext: C) => Nullable<T>;
|
||||
|
||||
/**
|
||||
* A helper to compose a certain type of interceptors.
|
||||
*/
|
||||
export const composeInterceptors = <T, C>(interceptors: Array<IInterceptor<T, C>>) =>
|
||||
|
||||
function (initialValue: Nullable<T>, context: C) {
|
||||
let index = -1;
|
||||
let value: Nullable<T> = initialValue;
|
||||
let nextCalled = false;
|
||||
|
||||
const next = (nextValue: Nullable<T>) => {
|
||||
nextCalled = true;
|
||||
return nextValue;
|
||||
};
|
||||
|
||||
for (let i = 0; i < interceptors.length; i++) {
|
||||
if (i <= index) {
|
||||
throw new Error('[SheetInterceptorService]: next() called multiple times!');
|
||||
}
|
||||
|
||||
index = i;
|
||||
|
||||
const interceptor = interceptors[i];
|
||||
nextCalled = false;
|
||||
|
||||
value = interceptor.handler!(value, context, next);
|
||||
|
||||
if (!nextCalled) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
} as ReturnType<IComposeInterceptors<T, C>>;
|
||||
|
||||
export class InterceptorManager<P extends Record<string, IInterceptor<any, any>>> {
|
||||
private _interceptorsByName: Map<string, Array<IInterceptor<unknown, unknown>>> = new Map();
|
||||
private _interceptorPoints: P;
|
||||
|
||||
constructor(interceptorPoints: P) {
|
||||
this._interceptorPoints = interceptorPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the interceptors.
|
||||
* @param name Name of the intercepted point.
|
||||
* @param filter A callback function to filter the interceptors.
|
||||
* @returns It will return a composed interceptor function. If you will perform the interceptor repeatedly,
|
||||
* you should cache the result instead of calling this function multiple times.
|
||||
*/
|
||||
public fetchThroughInterceptors<T, C>(name: IInterceptor<T, C>, filter?: (interceptor: IInterceptor<any, any>) => boolean) {
|
||||
const key = name as unknown as string;
|
||||
let interceptors = this._interceptorsByName.get(key) as unknown as Array<typeof name>;
|
||||
|
||||
if (filter) {
|
||||
interceptors = interceptors.filter(filter);
|
||||
}
|
||||
|
||||
return composeInterceptors(interceptors || []);
|
||||
}
|
||||
|
||||
public intercept<T extends IInterceptor<any, any>>(name: T, interceptor: T) {
|
||||
const key = name as unknown as string;
|
||||
if (!this._interceptorsByName.has(key)) {
|
||||
this._interceptorsByName.set(key, []);
|
||||
}
|
||||
const interceptors = this._interceptorsByName.get(key)!;
|
||||
interceptors.push(interceptor);
|
||||
|
||||
this._interceptorsByName.set(
|
||||
key,
|
||||
interceptors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // from large to small
|
||||
);
|
||||
return () => remove(this._interceptorsByName.get(key)!, interceptor);
|
||||
}
|
||||
|
||||
public getInterceptPoints() {
|
||||
return this._interceptorPoints;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this._interceptorsByName.clear();
|
||||
}
|
||||
}
|
||||
|
||||
export function createAsyncInterceptorKey<T, C>(key: string): IAsyncInterceptor<T, C> {
|
||||
const symbol = `sheet_async_interceptor_${key}`;
|
||||
return symbol as unknown as IAsyncInterceptor<T, C>;
|
||||
};
|
||||
|
||||
export type AsyncInterceptorHandler<M = unknown, C = unknown> = (
|
||||
value: Nullable<M>,
|
||||
context: C,
|
||||
next: (value: Nullable<M>) => Promise<Nullable<M>>
|
||||
) => Promise<Nullable<M>>;
|
||||
|
||||
export interface IAsyncInterceptor<M, C> {
|
||||
/**
|
||||
* The priority of the interceptor, the larger the number, the higher the priority.
|
||||
* @default 0
|
||||
*/
|
||||
priority?: number;
|
||||
handler: AsyncInterceptorHandler<M, C>;
|
||||
}
|
||||
|
||||
export type IComposeAsyncInterceptors<T = any, C = any> = (
|
||||
interceptors: Array<IAsyncInterceptor<T, C>>
|
||||
) => (initValue: Nullable<T>, initContext: C) => Promise<Nullable<T>>;
|
||||
|
||||
export const composeAsyncInterceptors = <T, C>(
|
||||
interceptors: Array<IAsyncInterceptor<T, C>>
|
||||
): ((initialValue: Nullable<T>, context: C) => Promise<Nullable<T>>) => {
|
||||
return async function (initialValue: Nullable<T>, context: C) {
|
||||
let index = -1;
|
||||
let value: Nullable<T> = initialValue;
|
||||
|
||||
for (let i = 0; i <= interceptors.length; i++) {
|
||||
if (i <= index) {
|
||||
throw new Error('[SheetInterceptorService]: next() called multiple times!');
|
||||
}
|
||||
|
||||
index = i;
|
||||
|
||||
if (i === interceptors.length) {
|
||||
return value;
|
||||
}
|
||||
|
||||
const interceptor = interceptors[i];
|
||||
let nextCalled = false;
|
||||
|
||||
value = await interceptor.handler!(value, context, async (nextValue) => {
|
||||
nextCalled = true;
|
||||
return nextValue;
|
||||
});
|
||||
|
||||
if (!nextCalled) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
};
|
||||
|
||||
export class AsyncInterceptorManager<P extends Record<string, IAsyncInterceptor<any, any>>> {
|
||||
private _asyncInterceptorsByName: Map<string, Array<IAsyncInterceptor<unknown, unknown>>> = new Map();
|
||||
private _asyncInterceptorPoints: P;
|
||||
|
||||
constructor(asyncInterceptorPoints: P) {
|
||||
this._asyncInterceptorPoints = asyncInterceptorPoints;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the interceptors.
|
||||
* @param name Name of the intercepted point.
|
||||
* @param filter A callback function to filter the interceptors.
|
||||
* @returns It will return a composed interceptor function. If you will perform the interceptor repeatedly,
|
||||
* you should cache the result instead of calling this function multiple times.
|
||||
*/
|
||||
public fetchThroughAsyncInterceptors<T, C>(name: IAsyncInterceptor<T, C>, filter?: (interceptor: IAsyncInterceptor<any, any>) => boolean) {
|
||||
const key = name as unknown as string;
|
||||
let interceptors = this._asyncInterceptorsByName.get(key) as unknown as Array<typeof name>;
|
||||
|
||||
if (filter) {
|
||||
interceptors = interceptors.filter(filter);
|
||||
}
|
||||
|
||||
return composeAsyncInterceptors(interceptors || []);
|
||||
}
|
||||
|
||||
public async interceptAsync<T extends IAsyncInterceptor<any, any>>(name: T, interceptor: T) {
|
||||
const key = name as unknown as string;
|
||||
if (!this._asyncInterceptorsByName.has(key)) {
|
||||
this._asyncInterceptorsByName.set(key, []);
|
||||
}
|
||||
|
||||
const interceptors = this._asyncInterceptorsByName.get(key)!;
|
||||
interceptors.push(interceptor);
|
||||
|
||||
this._asyncInterceptorsByName.set(
|
||||
key,
|
||||
interceptors.sort((a, b) => (b.priority ?? 0) - (a.priority ?? 0)) // from large to small
|
||||
);
|
||||
return () => remove(this._asyncInterceptorsByName.get(key)!, interceptor);
|
||||
}
|
||||
|
||||
public getInterceptPoints() {
|
||||
return this._asyncInterceptorPoints;
|
||||
}
|
||||
|
||||
public dispose() {
|
||||
this._asyncInterceptorsByName.clear();
|
||||
}
|
||||
}
|
||||
127
packages/core/src/common/invert-color/invert-hsl.ts
Normal file
127
packages/core/src/common/invert-color/invert-hsl.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { RGBColorType } from './utils';
|
||||
import { denormalizeRGBColor, normalizeRGBColor } from './utils';
|
||||
|
||||
function rgbToHsl([r, g, b]: RGBColorType): RGBColorType {
|
||||
const max = Math.max(r, g, b);
|
||||
const min = Math.min(r, g, b);
|
||||
const l = (max + min) / 2;
|
||||
|
||||
let h = 0;
|
||||
let s = 0;
|
||||
|
||||
if (max !== min) {
|
||||
const d = max - min;
|
||||
s = l > 0.5 ? d / (2 - max - min) : d / (max + min);
|
||||
switch (max) {
|
||||
case r:
|
||||
h = (g - b) / d + (g < b ? 6 : 0);
|
||||
break;
|
||||
case g:
|
||||
h = (b - r) / d + 2;
|
||||
break;
|
||||
case b:
|
||||
h = (r - g) / d + 4;
|
||||
break;
|
||||
}
|
||||
h /= 6;
|
||||
}
|
||||
|
||||
return [h, s, l];
|
||||
}
|
||||
|
||||
function getLuminance(r: number, g: number, b: number) {
|
||||
const a = [r, g, b].map((v) => {
|
||||
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
||||
});
|
||||
|
||||
return 0.2126 * a[0] + 0.7152 * a[1] + 0.0722 * a[2];
|
||||
}
|
||||
|
||||
function getContrastRatio(luminance1: number, luminance2: number) {
|
||||
return (
|
||||
(Math.max(luminance1, luminance2) + 0.05) /
|
||||
(Math.min(luminance1, luminance2) + 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
function hslToRgb(h: number, s: number, l: number): RGBColorType {
|
||||
let r, g, b;
|
||||
|
||||
if (s === 0) {
|
||||
r = g = b = l; // achromatic
|
||||
} else {
|
||||
const hue2rgb = (p: number, q: number, _t: number) => {
|
||||
let t = _t;
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
|
||||
const q = l < 0.5 ? l * (1 + s) : l + s - l * s;
|
||||
const p = 2 * l - q;
|
||||
|
||||
r = hue2rgb(p, q, h + 1 / 3);
|
||||
g = hue2rgb(p, q, h);
|
||||
b = hue2rgb(p, q, h - 1 / 3);
|
||||
}
|
||||
|
||||
return [r, g, b];
|
||||
}
|
||||
|
||||
export function stringToRgb(color: string): RGBColorType {
|
||||
return color.split(',').slice(0, 3).map(Number) as RGBColorType;
|
||||
}
|
||||
|
||||
const white = { r: 1, g: 1, b: 1 };
|
||||
const black = { r: 0, g: 0, b: 0 };
|
||||
|
||||
const whiteLuminance = getLuminance(white.r, white.g, white.b);
|
||||
const blackLuminance = getLuminance(black.r, black.g, black.b);
|
||||
|
||||
/**
|
||||
* Invert a color by HSL tunning method.
|
||||
* @param color The color to invert. Note that this color is already normalized.
|
||||
* @returns The inverted color.
|
||||
*/
|
||||
function invertNormalizedColorByHSL(color: RGBColorType): RGBColorType {
|
||||
const originalLuminance = getLuminance(color[0], color[1], color[2]);
|
||||
const originalContrast = getContrastRatio(
|
||||
whiteLuminance,
|
||||
originalLuminance
|
||||
);
|
||||
|
||||
const hsl = rgbToHsl(color);
|
||||
let l = 1 - hsl[2]; // Invert the lightness
|
||||
let newColor, newLuminance, newContrast;
|
||||
do {
|
||||
newColor = hslToRgb(hsl[0], hsl[1], l);
|
||||
newLuminance = getLuminance(newColor[0], newColor[1], newColor[2]);
|
||||
newContrast = getContrastRatio(newLuminance, blackLuminance);
|
||||
l += 0.01;
|
||||
} while (l <= 1 && l >= 0 && Math.abs(newContrast - originalContrast) < 0.01);
|
||||
|
||||
return newColor;
|
||||
}
|
||||
|
||||
export function invertColorByHSL(color: RGBColorType): RGBColorType {
|
||||
return denormalizeRGBColor(invertNormalizedColorByHSL(normalizeRGBColor(color)));
|
||||
}
|
||||
51
packages/core/src/common/invert-color/invert-rgb.ts
Normal file
51
packages/core/src/common/invert-color/invert-rgb.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { RGBColorType } from './utils';
|
||||
import { denormalizeRGBColor, normalizeRGBColor } from './utils';
|
||||
|
||||
const matrix = [
|
||||
[0.333, -0.667, -0.667, 0, 1],
|
||||
[-0.667, 0.333, -0.667, 0, 1],
|
||||
[-0.667, -0.667, 0.333, 0, 1],
|
||||
[0, 0, 0, 1, 0],
|
||||
];
|
||||
|
||||
function invertNormalizedColorByMatrix(color: RGBColorType): RGBColorType {
|
||||
const r = color[0];
|
||||
const g = color[1];
|
||||
const b = color[2];
|
||||
|
||||
let newColor = [
|
||||
matrix[0][0] * r + matrix[0][1] * g + matrix[0][2] * b + matrix[0][4],
|
||||
matrix[1][0] * r + matrix[1][1] * g + matrix[1][2] * b + matrix[1][4],
|
||||
matrix[2][0] * r + matrix[2][1] * g + matrix[2][2] * b + matrix[2][4],
|
||||
];
|
||||
|
||||
// Clamp values between 0 and 1, or we can normalize it.
|
||||
// It is better to normalize before rendering.
|
||||
newColor = newColor.map((c) => (c > 1 ? 1 : c < 0 ? 0 : c));
|
||||
return newColor as RGBColorType;
|
||||
}
|
||||
|
||||
/**
|
||||
* Invert a color by RGB matrix method.
|
||||
* @param color The color to invert. Note that this color is already normalized.
|
||||
* @returns The inverted color.
|
||||
*/
|
||||
export function invertColorByMatrix(color: RGBColorType): RGBColorType {
|
||||
return denormalizeRGBColor(invertNormalizedColorByMatrix(normalizeRGBColor(color)));
|
||||
}
|
||||
26
packages/core/src/common/invert-color/utils.ts
Normal file
26
packages/core/src/common/invert-color/utils.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type RGBColorType = [number, number, number];
|
||||
export type HSLColorType = RGBColorType;
|
||||
|
||||
export function normalizeRGBColor(color: RGBColorType): RGBColorType {
|
||||
return color.map((c) => c / 255) as RGBColorType;
|
||||
}
|
||||
|
||||
export function denormalizeRGBColor(color: RGBColorType): RGBColorType {
|
||||
return color.map((c) => Math.round(c * 255)) as RGBColorType;
|
||||
}
|
||||
23
packages/core/src/common/json.ts
Normal file
23
packages/core/src/common/json.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export type Serializable =
|
||||
| { [property: string]: Serializable }
|
||||
| readonly Serializable[]
|
||||
| string
|
||||
| number
|
||||
| boolean
|
||||
| null;
|
||||
17
packages/core/src/common/lodash.ts
Normal file
17
packages/core/src/common/lodash.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export { debounce, get, merge, mergeWith, set, setWith } from 'lodash-es';
|
||||
32
packages/core/src/common/memory-cursor.ts
Normal file
32
packages/core/src/common/memory-cursor.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class MemoryCursor {
|
||||
cursor: number = 0;
|
||||
|
||||
reset() {
|
||||
this.cursor = 0;
|
||||
return this;
|
||||
}
|
||||
|
||||
moveCursor(pos: number) {
|
||||
this.cursor += pos;
|
||||
}
|
||||
|
||||
moveCursorTo(pos: number) {
|
||||
this.cursor = pos;
|
||||
}
|
||||
}
|
||||
35
packages/core/src/common/mixin.ts
Normal file
35
packages/core/src/common/mixin.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
interface IMixinProperty<T> {
|
||||
// eslint-disable-next-line ts/no-explicit-any
|
||||
[fnName: string]: (this: T, ...args: any[]) => any;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mixin some methods to targetObject as prototype, the static methods will not be mixed in
|
||||
* @param {T} targetClassPrototype The target class to mixin
|
||||
* @param {IMixinProperty<T>} mixin The mixin object which contains the methods to mixin.
|
||||
*/
|
||||
export function mixinClass<T>(targetClassPrototype: T, mixin: IMixinProperty<T>): void {
|
||||
for (const key in mixin) {
|
||||
// eslint-disable-next-line no-prototype-builtins
|
||||
if (mixin.hasOwnProperty(key)) {
|
||||
// @ts-ignore
|
||||
targetClassPrototype[key] = mixin[key];
|
||||
}
|
||||
}
|
||||
}
|
||||
37
packages/core/src/common/number.ts
Normal file
37
packages/core/src/common/number.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function isNumeric(str: string): boolean {
|
||||
return /^-?\d+(\.\d+)?$/.test(str);
|
||||
}
|
||||
|
||||
export function isSafeNumeric(str: string): boolean {
|
||||
const numeric = isNumeric(str);
|
||||
if (!numeric) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Number(str) <= Number.MAX_SAFE_INTEGER;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the numeric string will lose precision when converted to a number.
|
||||
* e.g. '123456789123456789' -> 123456789123456780
|
||||
* e.g. '1212121212121212.2345' -> 1212121212121212.2
|
||||
*/
|
||||
export function willLoseNumericPrecision(str: string): boolean {
|
||||
return Number(str) > Number.MAX_SAFE_INTEGER || str.length >= 18;
|
||||
}
|
||||
210
packages/core/src/common/regexp/charset.ts
Normal file
210
packages/core/src/common/regexp/charset.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRegExpFromSafeFragment } from './factory';
|
||||
|
||||
export type CharsetDataUnit = [number, number];
|
||||
|
||||
/**
|
||||
* A single character, code point, or inclusive range used to build a character class.
|
||||
*
|
||||
* String inputs must contain exactly one Unicode code point. Use `or()` for multi-character
|
||||
* alternatives such as keywords or table names.
|
||||
*/
|
||||
export type CharsetRawInput = string | number | [string, string] | [number, number];
|
||||
export type CharsetInput = Charset | CharsetRawInput;
|
||||
|
||||
const MAX_CODE_POINT = 0x10FFFF;
|
||||
|
||||
/**
|
||||
* Builds a normalized regular-expression character class.
|
||||
*
|
||||
* `Charset` is intended for dynamic character sets, especially when callers need set
|
||||
* operations that are hard to read or easy to get wrong with handwritten `[...]` strings.
|
||||
* Ranges are stored as inclusive Unicode code-point pairs and merged during construction.
|
||||
*/
|
||||
export class Charset {
|
||||
readonly data: CharsetDataUnit[];
|
||||
|
||||
constructor(...inputs: CharsetInput[]) {
|
||||
this.data = normalizeData(inputs.flatMap((input) => inputToData(input)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new charset containing this charset and all provided characters or ranges.
|
||||
*/
|
||||
union(...inputs: CharsetInput[]): Charset {
|
||||
return new Charset(this, ...inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new charset with the provided characters or ranges removed.
|
||||
*/
|
||||
subtract(...inputs: CharsetInput[]): Charset {
|
||||
const subtractData = normalizeData(inputs.flatMap((input) => inputToData(input)));
|
||||
let next = this.data.slice();
|
||||
|
||||
for (const [removeStart, removeEnd] of subtractData) {
|
||||
const fragments: CharsetDataUnit[] = [];
|
||||
|
||||
for (const [start, end] of next) {
|
||||
if (removeEnd < start || removeStart > end) {
|
||||
fragments.push([start, end]);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (removeStart > start) {
|
||||
fragments.push([start, removeStart - 1]);
|
||||
}
|
||||
|
||||
if (removeEnd < end) {
|
||||
fragments.push([removeEnd + 1, end]);
|
||||
}
|
||||
}
|
||||
|
||||
next = fragments;
|
||||
}
|
||||
|
||||
return new Charset(...next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new charset containing only characters shared with the provided inputs.
|
||||
*/
|
||||
intersect(...inputs: CharsetInput[]): Charset {
|
||||
const intersectData = normalizeData(inputs.flatMap((input) => inputToData(input)));
|
||||
const next: CharsetDataUnit[] = [];
|
||||
|
||||
for (const [start, end] of this.data) {
|
||||
for (const [intersectStart, intersectEnd] of intersectData) {
|
||||
const nextStart = Math.max(start, intersectStart);
|
||||
const nextEnd = Math.min(end, intersectEnd);
|
||||
|
||||
if (nextStart <= nextEnd) {
|
||||
next.push([nextStart, nextEnd]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return new Charset(...next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this charset would match no characters.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.data.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes to a regex fragment, not a full anchored pattern.
|
||||
*/
|
||||
toString(): string {
|
||||
if (this.isEmpty()) {
|
||||
return '(?!)';
|
||||
}
|
||||
|
||||
return `[${this.data.map(rangeToString).join('')}]`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `RegExp` from this charset fragment with optional flags.
|
||||
*/
|
||||
toRegExp(flags?: string): RegExp {
|
||||
return createRegExpFromSafeFragment(this.toString(), flags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory for `new Charset(...)`.
|
||||
*/
|
||||
export const charset = (...inputs: CharsetInput[]): Charset => new Charset(...inputs);
|
||||
|
||||
function inputToData(input: CharsetInput): CharsetDataUnit[] {
|
||||
if (input instanceof Charset) {
|
||||
return input.data;
|
||||
}
|
||||
|
||||
if (Array.isArray(input)) {
|
||||
const start = codePoint(input[0]);
|
||||
const end = codePoint(input[1]);
|
||||
|
||||
return [[Math.min(start, end), Math.max(start, end)]];
|
||||
}
|
||||
|
||||
const point = codePoint(input);
|
||||
return [[point, point]];
|
||||
}
|
||||
|
||||
function codePoint(input: string | number): number {
|
||||
if (typeof input === 'number') {
|
||||
if (!Number.isInteger(input) || input < 0 || input > MAX_CODE_POINT) {
|
||||
throw new RangeError(`Invalid code point: ${input}`);
|
||||
}
|
||||
|
||||
return input;
|
||||
}
|
||||
|
||||
const point = input.codePointAt(0);
|
||||
if (point == null || String.fromCodePoint(point) !== input) {
|
||||
throw new RangeError(`Charset string inputs must contain exactly one code point: ${input}`);
|
||||
}
|
||||
|
||||
return point;
|
||||
}
|
||||
|
||||
function normalizeData(data: CharsetDataUnit[]): CharsetDataUnit[] {
|
||||
if (data.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sorted = data.slice().sort((a, b) => a[0] - b[0] || a[1] - b[1]);
|
||||
const result: CharsetDataUnit[] = [];
|
||||
|
||||
for (const [start, end] of sorted) {
|
||||
const last = result[result.length - 1];
|
||||
|
||||
if (!last || start > last[1] + 1) {
|
||||
result.push([start, end]);
|
||||
} else {
|
||||
last[1] = Math.max(last[1], end);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function rangeToString([start, end]: CharsetDataUnit): string {
|
||||
if (start === end) {
|
||||
return escapeCharClassCodePoint(start);
|
||||
}
|
||||
|
||||
return `${escapeCharClassCodePoint(start)}-${escapeCharClassCodePoint(end)}`;
|
||||
}
|
||||
|
||||
function escapeCharClassCodePoint(point: number): string {
|
||||
const char = String.fromCodePoint(point);
|
||||
|
||||
if (char === '\\' || char === ']' || char === '-' || char === '^') {
|
||||
return `\\${char}`;
|
||||
}
|
||||
|
||||
if (point <= 0x1F || point === 0x7F) {
|
||||
return `\\u${point.toString(16).padStart(4, '0')}`;
|
||||
}
|
||||
|
||||
return char;
|
||||
}
|
||||
43
packages/core/src/common/regexp/escape.ts
Normal file
43
packages/core/src/common/regexp/escape.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRegExpFromSafeFragment } from './factory';
|
||||
|
||||
/**
|
||||
* Escapes text so it can be embedded in a regular-expression pattern as a literal.
|
||||
*/
|
||||
export function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `RegExp` that matches literal text rather than regular-expression syntax.
|
||||
*/
|
||||
export function createLiteralRegExp(value: string, flags?: string): RegExp {
|
||||
return createRegExpFromSafeFragment(escapeRegExp(value), flags);
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a spreadsheet-style wildcard pattern to an anchored regular expression.
|
||||
*
|
||||
* `*` matches any number of characters and `?` matches one character. All other
|
||||
* regular-expression syntax is treated as literal text. Matching is case-insensitive.
|
||||
*/
|
||||
export function createFromWildcard(pattern: string): RegExp {
|
||||
const escaped = escapeRegExp(pattern);
|
||||
const source = escaped.replace(/\\\*/g, '.*').replace(/\\\?/g, '.');
|
||||
return createRegExpFromSafeFragment(`^${source}$`, 'i');
|
||||
}
|
||||
41
packages/core/src/common/regexp/factory.ts
Normal file
41
packages/core/src/common/regexp/factory.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const REGEXP_FLAGS = new Set(['d', 'g', 'i', 'm', 's', 'u', 'v', 'y']);
|
||||
|
||||
export function createRegExpFromSafeFragment(fragment: string, flags?: string): RegExp {
|
||||
const safeFlags = normalizeRegExpFlags(flags);
|
||||
|
||||
// Fragments passed here must come from regexp helpers that escape literals or normalize charsets.
|
||||
return new RegExp(fragment, safeFlags); // nosemgrep: javascript.lang.security.audit.detect-non-literal-regexp.detect-non-literal-regexp
|
||||
}
|
||||
|
||||
function normalizeRegExpFlags(flags: string | undefined): string | undefined {
|
||||
if (flags === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const seen = new Set<string>();
|
||||
for (const flag of flags) {
|
||||
if (!REGEXP_FLAGS.has(flag) || seen.has(flag)) {
|
||||
throw new SyntaxError(`Invalid regular expression flags: ${flags}`);
|
||||
}
|
||||
|
||||
seen.add(flag);
|
||||
}
|
||||
|
||||
return flags;
|
||||
}
|
||||
39
packages/core/src/common/regexp/index.ts
Normal file
39
packages/core/src/common/regexp/index.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { Charset, charset } from './charset';
|
||||
import { createFromWildcard, createLiteralRegExp, escapeRegExp } from './escape';
|
||||
import { createRegExpFromSafeFragment } from './factory';
|
||||
import { Or, or } from './or';
|
||||
|
||||
/**
|
||||
* Namespace-style public API for regex helpers.
|
||||
*
|
||||
* Import this as `regexp` and call methods through the namespace, for example
|
||||
* `regexp.escapeRegExp(value)` or `regexp.or(...names)`. Do not export these
|
||||
* helpers individually from the package root; keeping one namespace avoids
|
||||
* scattered regex APIs and accidental name collisions.
|
||||
*/
|
||||
export const regexp = {
|
||||
Charset,
|
||||
Or,
|
||||
charset,
|
||||
or,
|
||||
createRegExpFromSafeFragment,
|
||||
escapeRegExp,
|
||||
createLiteralRegExp,
|
||||
createFromWildcard,
|
||||
};
|
||||
108
packages/core/src/common/regexp/or.ts
Normal file
108
packages/core/src/common/regexp/or.ts
Normal file
@@ -0,0 +1,108 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { CharsetInput } from './charset';
|
||||
import { Charset, charset } from './charset';
|
||||
import { escapeRegExp } from './escape';
|
||||
import { createRegExpFromSafeFragment } from './factory';
|
||||
|
||||
/**
|
||||
* A literal string, existing `Or`, or character-set input used to build alternatives.
|
||||
*/
|
||||
export type OrInput = string | Or | CharsetInput;
|
||||
|
||||
/**
|
||||
* Builds a regex alternation from literal strings and character sets.
|
||||
*
|
||||
* Literal strings are escaped automatically. Character-set inputs are folded into one
|
||||
* `Charset`, so `or('SUM', charset('A', 'B'))` serializes to an alternation containing
|
||||
* both the string branch and the character-class branch.
|
||||
*/
|
||||
export class Or {
|
||||
readonly charset: Charset;
|
||||
readonly strings: string[];
|
||||
|
||||
constructor(...inputs: OrInput[]) {
|
||||
const charsetInputs: CharsetInput[] = [];
|
||||
const strings: string[] = [];
|
||||
|
||||
for (const input of inputs) {
|
||||
if (input instanceof Or) {
|
||||
charsetInputs.push(input.charset);
|
||||
strings.push(...input.strings);
|
||||
} else if (input instanceof Charset || typeof input === 'number' || Array.isArray(input)) {
|
||||
charsetInputs.push(input);
|
||||
} else {
|
||||
strings.push(input);
|
||||
}
|
||||
}
|
||||
|
||||
this.charset = charset(...charsetInputs);
|
||||
this.strings = Array.from(new Set(strings));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new alternation containing this instance and all provided inputs.
|
||||
*/
|
||||
union(...inputs: OrInput[]): Or {
|
||||
return new Or(this, ...inputs);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a new alternation with matching string and charset inputs removed.
|
||||
*/
|
||||
subtract(...inputs: OrInput[]): Or {
|
||||
const other = new Or(...inputs);
|
||||
const nextStrings = this.strings.filter((item) => !other.strings.includes(item));
|
||||
|
||||
return new Or(this.charset.subtract(other.charset), ...nextStrings);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether this alternation would match no branches.
|
||||
*/
|
||||
isEmpty(): boolean {
|
||||
return this.charset.isEmpty() && this.strings.length === 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes to a regex fragment, not a full anchored pattern.
|
||||
*/
|
||||
toString(): string {
|
||||
if (this.isEmpty()) {
|
||||
return '(?!)';
|
||||
}
|
||||
|
||||
const alternatives = [
|
||||
...(!this.charset.isEmpty() ? [this.charset.toString()] : []),
|
||||
...this.strings.map(escapeRegExp),
|
||||
];
|
||||
|
||||
return alternatives.length === 1 ? alternatives[0] : `(?:${alternatives.join('|')})`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a `RegExp` from this alternation fragment with optional flags.
|
||||
*/
|
||||
toRegExp(flags?: string): RegExp {
|
||||
return createRegExpFromSafeFragment(this.toString(), flags);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Convenience factory for `new Or(...)`.
|
||||
*/
|
||||
export const or = (...inputs: OrInput[]): Or => new Or(...inputs);
|
||||
68
packages/core/src/common/registry.ts
Normal file
68
packages/core/src/common/registry.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export class Registry<T = any> {
|
||||
private _data: T[] = [];
|
||||
|
||||
static create<T = any>() {
|
||||
return new Registry<T>();
|
||||
}
|
||||
|
||||
add(dataInstance: T) {
|
||||
if (this._data.indexOf(dataInstance) > -1) {
|
||||
return;
|
||||
}
|
||||
this._data.push(dataInstance);
|
||||
}
|
||||
|
||||
delete(dataInstance: T) {
|
||||
const index = this._data.indexOf(dataInstance);
|
||||
this._data.splice(index, 1);
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add extension modules statically when the plugin is initialized, so that the plugin can register these extension modules uniformly
|
||||
*
|
||||
* @privateRemarks
|
||||
* zh: 在插件初始化的时候静态添加扩展模块,方便插件统一注册这些扩展模块
|
||||
*/
|
||||
export class RegistryAsMap {
|
||||
private _data: Map<string, any> = new Map();
|
||||
|
||||
static create() {
|
||||
return new RegistryAsMap();
|
||||
}
|
||||
|
||||
add(id: string, dataInstance: any) {
|
||||
if (this._data.has(id)) {
|
||||
return;
|
||||
}
|
||||
this._data.set(id, dataInstance);
|
||||
}
|
||||
|
||||
delete(id: string) {
|
||||
this._data.delete(id);
|
||||
}
|
||||
|
||||
getData() {
|
||||
return this._data;
|
||||
}
|
||||
}
|
||||
47
packages/core/src/common/request-immediate-macro-task.ts
Normal file
47
packages/core/src/common/request-immediate-macro-task.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export function requestImmediateMacroTask(callback: (value?: unknown) => void): () => void {
|
||||
const channel = new MessageChannel();
|
||||
let cancelled = false;
|
||||
|
||||
const close = () => {
|
||||
channel.port1.onmessage = null;
|
||||
channel.port1.close();
|
||||
channel.port2.close();
|
||||
};
|
||||
|
||||
const handler = () => {
|
||||
if (!cancelled) {
|
||||
cancelled = true;
|
||||
close();
|
||||
callback();
|
||||
}
|
||||
};
|
||||
|
||||
// Use onmessage for web worker compatibility and clear it when the task settles.
|
||||
channel.port1.onmessage = handler;
|
||||
channel.port2.postMessage(null);
|
||||
|
||||
return () => {
|
||||
if (cancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
cancelled = true;
|
||||
close();
|
||||
};
|
||||
}
|
||||
81
packages/core/src/common/sequence.ts
Normal file
81
packages/core/src/common/sequence.ts
Normal file
@@ -0,0 +1,81 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export interface ISequenceExecuteResult {
|
||||
index: number;
|
||||
result: boolean;
|
||||
error?: unknown;
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute promise tasks in sequence, if one of the tasks return false, the sequence will be stopped.
|
||||
* @param tasks All promise tasks that need to be triggered.
|
||||
* @returns Returns `true` if all tasks are executed successfully, otherwise `false` and index of the task that returns false.
|
||||
*/
|
||||
export async function sequenceAsync(tasks: Array<() => Promise<boolean> | boolean>): Promise<ISequenceExecuteResult> {
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
try {
|
||||
const result = await task();
|
||||
if (!result) {
|
||||
return {
|
||||
index,
|
||||
result: false,
|
||||
};
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return {
|
||||
index,
|
||||
result: false,
|
||||
error: e,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
index: -1,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute tasks in sequence, if one of the tasks return false, the sequence will be stopped.
|
||||
* @param tasks All tasks that need to be triggered.
|
||||
* @returns Returns `true` if all tasks are executed successfully, otherwise `false` and index of the task that returns false.
|
||||
*/
|
||||
export function sequence(tasks: Array<() => boolean>): ISequenceExecuteResult {
|
||||
for (const [index, task] of tasks.entries()) {
|
||||
try {
|
||||
const result = task();
|
||||
if (!result) {
|
||||
return {
|
||||
index,
|
||||
result: false,
|
||||
};
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
return {
|
||||
index,
|
||||
result: false,
|
||||
error: e,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
result: true,
|
||||
index: -1,
|
||||
};
|
||||
}
|
||||
26
packages/core/src/common/set.ts
Normal file
26
packages/core/src/common/set.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Merge the second set to the first set.
|
||||
* @param s1 the first set
|
||||
* @param s2 the second set
|
||||
* @returns the merged set
|
||||
*/
|
||||
export function mergeSets<T>(s1: Set<T>, s2: Set<T>): Set<T> {
|
||||
s2.forEach((s) => s1.add(s));
|
||||
return s1;
|
||||
}
|
||||
133
packages/core/src/common/shims.ts
Normal file
133
packages/core/src/common/shims.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
const glob = typeof global !== 'undefined' ? global : typeof self !== 'undefined' ? self : window;
|
||||
|
||||
/**
|
||||
* Polyfill for requestIdleCallback and cancelIdleCallback
|
||||
*/
|
||||
function installRequestIdleCallback() {
|
||||
const TIME_WINDOW = 50;
|
||||
|
||||
const idleCallbacks = new Map<number, NodeJS.Timeout>();
|
||||
let currentId = 0;
|
||||
|
||||
if (typeof glob.requestIdleCallback !== 'function') {
|
||||
glob.requestIdleCallback = function shimRIC(callback: Function) {
|
||||
const start = Date.now();
|
||||
const id = ++currentId;
|
||||
|
||||
const timeoutId = setTimeout(function rICCallback() {
|
||||
idleCallbacks.delete(id);
|
||||
const remaining = Math.max(0, TIME_WINDOW - (Date.now() - start));
|
||||
callback({
|
||||
didTimeout: remaining === 0,
|
||||
timeRemaining() {
|
||||
return remaining;
|
||||
},
|
||||
});
|
||||
}, 1);
|
||||
|
||||
idleCallbacks.set(id, timeoutId);
|
||||
return id;
|
||||
};
|
||||
}
|
||||
|
||||
if (typeof glob.cancelIdleCallback !== 'function') {
|
||||
glob.cancelIdleCallback = function shimCancelRIC(id: number) {
|
||||
const timeoutId = idleCallbacks.get(id);
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId);
|
||||
idleCallbacks.delete(id);
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill for queueMicrotask
|
||||
*/
|
||||
function installQueueMicrotask() {
|
||||
if (typeof glob.queueMicrotask !== 'function') {
|
||||
glob.queueMicrotask = function shimQueueMicrotask(callback: VoidFunction) {
|
||||
Promise.resolve()
|
||||
.then(callback)
|
||||
.catch((error) => {
|
||||
setTimeout(() => {
|
||||
throw error;
|
||||
}, 0);
|
||||
});
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill for Array.prototype.findLastIndex and Array.prototype.findLast
|
||||
*/
|
||||
function installArrayFindLastIndex() {
|
||||
if (typeof glob.Array.prototype.findLastIndex !== 'function') {
|
||||
glob.Array.prototype.findLastIndex = function findLastIndex(callback: (value: any, index: number, array: any[]) => boolean, thisArg?: any): number {
|
||||
if (this == null) {
|
||||
throw new TypeError('Array.prototype.findLastIndex called on null or undefined');
|
||||
}
|
||||
if (typeof callback !== 'function') {
|
||||
throw new TypeError('callback must be a function');
|
||||
}
|
||||
|
||||
const len = this.length >>> 0;
|
||||
for (let i = len - 1; i >= 0; i--) {
|
||||
if (i in this && callback.call(thisArg, this[i], i, this)) {
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
};
|
||||
}
|
||||
if (typeof glob.Array.prototype.findLast !== 'function') {
|
||||
glob.Array.prototype.findLast = function findLast(callback: (value: any, index: number, array: any[]) => boolean, thisArg?: any): any {
|
||||
const index = this.findLastIndex(callback, thisArg);
|
||||
return index !== -1 ? this[index] : undefined;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Polyfill for String.prototype.at
|
||||
*/
|
||||
function installStringAt() {
|
||||
if (typeof glob.String.prototype.at !== 'function') {
|
||||
glob.String.prototype.at = function at(index: number): string | undefined {
|
||||
if (this == null) {
|
||||
throw new TypeError('String.prototype.at called on null or undefined');
|
||||
}
|
||||
const len = this.length;
|
||||
if (index < 0) {
|
||||
index = len + index;
|
||||
}
|
||||
if (index < 0 || index >= len) {
|
||||
return undefined;
|
||||
}
|
||||
return this.charAt(index);
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function installShims() {
|
||||
installRequestIdleCallback();
|
||||
installQueueMicrotask();
|
||||
installArrayFindLastIndex();
|
||||
installStringAt();
|
||||
}
|
||||
42
packages/core/src/common/unit.ts
Normal file
42
packages/core/src/common/unit.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Observable } from 'rxjs';
|
||||
import { UniverType as UniverInstanceType } from '@univerjs/protocol';
|
||||
import { Disposable } from '../shared/lifecycle';
|
||||
|
||||
export { UniverInstanceType };
|
||||
|
||||
/**
|
||||
* The base class for all units.
|
||||
*/
|
||||
export abstract class UnitModel<D = object, T extends UniverInstanceType = UniverInstanceType> extends Disposable {
|
||||
abstract readonly type: T;
|
||||
|
||||
abstract getUnitId(): string;
|
||||
|
||||
abstract name$: Observable<string>;
|
||||
abstract setName(name: string): void;
|
||||
|
||||
abstract getSnapshot(): D;
|
||||
|
||||
/** Get revision of the unit's snapshot. Note that revision should start from 1. */
|
||||
abstract getRev(): number;
|
||||
/** Increment the current revision. */
|
||||
abstract incrementRev(): void;
|
||||
/** Set revision of the current snapshot. */
|
||||
abstract setRev(rev: number): void;
|
||||
}
|
||||
400
packages/core/src/common/url.ts
Normal file
400
packages/core/src/common/url.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
export const topLevelDomainSet = new Set([
|
||||
'ac',
|
||||
'ad',
|
||||
'ae',
|
||||
'aero',
|
||||
'af',
|
||||
'ag',
|
||||
'ai',
|
||||
'al',
|
||||
'am',
|
||||
'ao',
|
||||
'aq',
|
||||
'ar',
|
||||
'arpa',
|
||||
'as',
|
||||
'asia',
|
||||
'at',
|
||||
'au',
|
||||
'aw',
|
||||
'ax',
|
||||
'az',
|
||||
'ba',
|
||||
'bb',
|
||||
'bd',
|
||||
'be',
|
||||
'bf',
|
||||
'bg',
|
||||
'bh',
|
||||
'bi',
|
||||
'biz',
|
||||
'bj',
|
||||
'bm',
|
||||
'bn',
|
||||
'bo',
|
||||
'br',
|
||||
'bs',
|
||||
'bt',
|
||||
'bv',
|
||||
'bw',
|
||||
'by',
|
||||
'bz',
|
||||
'ca',
|
||||
'cat',
|
||||
'cc',
|
||||
'cd',
|
||||
'cf',
|
||||
'cg',
|
||||
'ch',
|
||||
'ci',
|
||||
'ck',
|
||||
'cl',
|
||||
'cm',
|
||||
'cn',
|
||||
'co',
|
||||
'com',
|
||||
'coop',
|
||||
'cr',
|
||||
'cu',
|
||||
'cv',
|
||||
'cw',
|
||||
'cx',
|
||||
'cy',
|
||||
'cz',
|
||||
'de',
|
||||
'dj',
|
||||
'dk',
|
||||
'dm',
|
||||
'do',
|
||||
'dz',
|
||||
'ec',
|
||||
'edu',
|
||||
'ee',
|
||||
'eg',
|
||||
'er',
|
||||
'es',
|
||||
'et',
|
||||
'eu',
|
||||
'fi',
|
||||
'fj',
|
||||
'fk',
|
||||
'fm',
|
||||
'fo',
|
||||
'fr',
|
||||
'ga',
|
||||
'gb',
|
||||
'gd',
|
||||
'ge',
|
||||
'gf',
|
||||
'gg',
|
||||
'gh',
|
||||
'gi',
|
||||
'gl',
|
||||
'gm',
|
||||
'gn',
|
||||
'gov',
|
||||
'gp',
|
||||
'gq',
|
||||
'gr',
|
||||
'gs',
|
||||
'gt',
|
||||
'gu',
|
||||
'gw',
|
||||
'gy',
|
||||
'hk',
|
||||
'hm',
|
||||
'hn',
|
||||
'hr',
|
||||
'ht',
|
||||
'hu',
|
||||
'id',
|
||||
'ie',
|
||||
'il',
|
||||
'im',
|
||||
'in',
|
||||
'info',
|
||||
'int',
|
||||
'io',
|
||||
'iq',
|
||||
'ir',
|
||||
'is',
|
||||
'it',
|
||||
'je',
|
||||
'jm',
|
||||
'jo',
|
||||
'jobs',
|
||||
'jp',
|
||||
'ke',
|
||||
'kg',
|
||||
'kh',
|
||||
'ki',
|
||||
'km',
|
||||
'kn',
|
||||
'kp',
|
||||
'kr',
|
||||
'kw',
|
||||
'ky',
|
||||
'kz',
|
||||
'la',
|
||||
'lb',
|
||||
'lc',
|
||||
'li',
|
||||
'lk',
|
||||
'lr',
|
||||
'ls',
|
||||
'lt',
|
||||
'lu',
|
||||
'lv',
|
||||
'ly',
|
||||
'ma',
|
||||
'mc',
|
||||
'md',
|
||||
'me',
|
||||
'mg',
|
||||
'mh',
|
||||
'mil',
|
||||
'mk',
|
||||
'ml',
|
||||
'mm',
|
||||
'mn',
|
||||
'mo',
|
||||
'mobi',
|
||||
'mp',
|
||||
'mq',
|
||||
'mr',
|
||||
'ms',
|
||||
'mt',
|
||||
'mu',
|
||||
'museum',
|
||||
'mv',
|
||||
'mw',
|
||||
'mx',
|
||||
'my',
|
||||
'mz',
|
||||
'na',
|
||||
'name',
|
||||
'nc',
|
||||
'ne',
|
||||
'net',
|
||||
'nf',
|
||||
'ng',
|
||||
'ni',
|
||||
'nl',
|
||||
'no',
|
||||
'np',
|
||||
'nr',
|
||||
'nu',
|
||||
'nz',
|
||||
'om',
|
||||
'onion',
|
||||
'org',
|
||||
'pa',
|
||||
'pe',
|
||||
'pf',
|
||||
'pg',
|
||||
'ph',
|
||||
'pk',
|
||||
'pl',
|
||||
'pm',
|
||||
'pn',
|
||||
'post',
|
||||
'pr',
|
||||
'pro',
|
||||
'ps',
|
||||
'pt',
|
||||
'pw',
|
||||
'py',
|
||||
'qa',
|
||||
're',
|
||||
'ro',
|
||||
'rs',
|
||||
'ru',
|
||||
'rw',
|
||||
'sa',
|
||||
'sb',
|
||||
'sc',
|
||||
'sd',
|
||||
'se',
|
||||
'sg',
|
||||
'sh',
|
||||
'si',
|
||||
'sj',
|
||||
'sk',
|
||||
'sl',
|
||||
'sm',
|
||||
'sn',
|
||||
'so',
|
||||
'sr',
|
||||
'ss',
|
||||
'st',
|
||||
'su',
|
||||
'sv',
|
||||
'sx',
|
||||
'sy',
|
||||
'sz',
|
||||
'tc',
|
||||
'td',
|
||||
'tel',
|
||||
'tf',
|
||||
'tg',
|
||||
'th',
|
||||
'tj',
|
||||
'tk',
|
||||
'tl',
|
||||
'tm',
|
||||
'tn',
|
||||
'to',
|
||||
'tr',
|
||||
'tt',
|
||||
'tv',
|
||||
'tw',
|
||||
'tz',
|
||||
'ua',
|
||||
'ug',
|
||||
'uk',
|
||||
'us',
|
||||
'uy',
|
||||
'uz',
|
||||
'va',
|
||||
'vc',
|
||||
've',
|
||||
'vg',
|
||||
'vi',
|
||||
'vn',
|
||||
'vu',
|
||||
'wf',
|
||||
'ws',
|
||||
'yt',
|
||||
'za',
|
||||
'zm',
|
||||
'zw',
|
||||
]);
|
||||
|
||||
const re_weburl = new RegExp(
|
||||
'^' +
|
||||
// protocol identifier (optional)
|
||||
// short syntax // still required
|
||||
'(?:(?:(?:https?|ftp):)?\\/\\/)?' +
|
||||
// user:pass BasicAuth (optional)
|
||||
'(?:\\S+(?::\\S*)?@)?' +
|
||||
'(?:' +
|
||||
// IP address dotted notation octets
|
||||
// excludes loopback network 0.0.0.0
|
||||
// excludes reserved space >= 224.0.0.0
|
||||
// excludes network & broadcast addresses
|
||||
// (first & last IP address of each class)
|
||||
'(?:[1-9]\\d?|1\\d\\d|2[01]\\d|22[0-3])' +
|
||||
'(?:\\.(?:1?\\d{1,2}|2[0-4]\\d|25[0-5])){2}' +
|
||||
'(?:\\.(?:[1-9]\\d?|1\\d\\d|2[0-4]\\d|25[0-4]))' +
|
||||
'|' +
|
||||
// host & domain names, may end with dot
|
||||
// can be replaced by a shortest alternative
|
||||
// (?![-_])(?:[-\\w\\u00a1-\\uffff]{0,63}[^-_]\\.)+
|
||||
'(?:' +
|
||||
'(?:' +
|
||||
'[a-z0-9\\u00a1-\\uffff]' +
|
||||
'[a-z0-9\\u00a1-\\uffff_-]{0,62}' +
|
||||
')?' +
|
||||
'[a-z0-9\\u00a1-\\uffff]\\.' +
|
||||
')+' +
|
||||
// TLD identifier name, may end with dot
|
||||
'(?:[a-z\\u00a1-\\uffff]{2,}\\.?)' +
|
||||
')' +
|
||||
// port number (optional)
|
||||
'(?::\\d{2,5})?' +
|
||||
// resource path (optional)
|
||||
'(?:[/?#]\\S*)?' +
|
||||
'$',
|
||||
'i'
|
||||
);
|
||||
export function isLegalUrl(url: string) {
|
||||
// skip number 0.1
|
||||
if (!Number.isNaN(+url)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (url.startsWith('http://localhost:5173') || url.startsWith('localhost:5173')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const legal = re_weburl.test(url);
|
||||
|
||||
if (legal) {
|
||||
if (hasProtocol(url)) {
|
||||
return true;
|
||||
} else {
|
||||
try {
|
||||
const urlObj = new URL(normalizeUrl(url));
|
||||
const topLevelDomain = urlObj.hostname.split('.').pop();
|
||||
if (topLevelDomain && topLevelDomainSet.has(topLevelDomain)) {
|
||||
return true;
|
||||
};
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasProtocol(urlString: string) {
|
||||
const pattern = /^[a-zA-Z]+:\/\//;
|
||||
return pattern.test(urlString);
|
||||
}
|
||||
|
||||
function isEmail(url: string) {
|
||||
const pattern = /^[\w-\.]+@([\w-]+\.)+[\w-]{2,4}$/;
|
||||
return pattern.test(url);
|
||||
}
|
||||
|
||||
export function normalizeUrl(urlStr: string) {
|
||||
return hasProtocol(urlStr) ? urlStr : isEmail(urlStr) ? `mailto://${urlStr}` : `https://${urlStr}`;
|
||||
}
|
||||
|
||||
export function isSafeUrl(url: string): boolean {
|
||||
if (!url || typeof url !== 'string') {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const base = typeof window !== 'undefined' && window.location ? window.location.origin : 'http://localhost';
|
||||
const parsed = new URL(url, base);
|
||||
return ['http:', 'https:', 'mailto:'].includes(parsed.protocol);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve a URL with a base URL, ensuring the path from the base URL is preserved.
|
||||
* @param {string} url - The URL to resolve.
|
||||
* @param {string} baseURL - The base URL to use for resolution.
|
||||
* @returns {string} - The resolved URL.
|
||||
*/
|
||||
export function resolveWithBasePath(url: string, baseURL: string): string {
|
||||
try {
|
||||
const base = new URL(baseURL);
|
||||
const basePath = base.pathname.endsWith('/') ? base.pathname : `${base.pathname}/`;
|
||||
const cleanedUrl = url.startsWith('/') ? url.substring(1) : url;
|
||||
return new URL(cleanedUrl, base.origin + basePath).toString();
|
||||
} catch (error) {
|
||||
console.error('Error resolving URL with base URL:', error);
|
||||
return url;
|
||||
}
|
||||
}
|
||||
88
packages/core/src/docs/__tests__/paragraph-id.spec.ts
Normal file
88
packages/core/src/docs/__tests__/paragraph-id.spec.ts
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody } from '../../types/interfaces/i-document-data';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
cloneBodyWithFreshParagraphIds,
|
||||
cloneParagraphWithId,
|
||||
PARAGRAPH_ID_PREFIX,
|
||||
} from '../paragraph-id';
|
||||
|
||||
function createBody(): IDocumentBody {
|
||||
return {
|
||||
dataStream: 'Alpha\rBeta\r\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 5, paragraphId: 'para_alpha' },
|
||||
{ startIndex: 10, paragraphId: 'para_existing' },
|
||||
],
|
||||
sectionBreaks: [{ sectionId: 'section_fixture_1', startIndex: 11 }],
|
||||
};
|
||||
}
|
||||
|
||||
describe('paragraph id helpers', () => {
|
||||
it('clones paste/import fragments with fresh unique ids while preserving paragraph count and start indexes', () => {
|
||||
const source = createBody();
|
||||
const cloned = cloneBodyWithFreshParagraphIds(source, { unitId: 'doc-2', segmentId: '' });
|
||||
|
||||
expect(cloned.paragraphs).toHaveLength(source.paragraphs!.length);
|
||||
expect(cloned.paragraphs?.map((paragraph) => paragraph.startIndex)).toEqual(source.paragraphs?.map((paragraph) => paragraph.startIndex));
|
||||
expect(cloned.paragraphs?.map((paragraph) => paragraph.paragraphId)).not.toEqual(source.paragraphs?.map((paragraph) => paragraph.paragraphId));
|
||||
expect(new Set(cloned.paragraphs?.map((paragraph) => paragraph.paragraphId)).size).toBe(cloned.paragraphs?.length);
|
||||
expect(cloned.paragraphs?.every((paragraph) => paragraph.paragraphId?.startsWith(PARAGRAPH_ID_PREFIX))).toBe(true);
|
||||
});
|
||||
|
||||
it('preserves a valid unique id when cloning a paragraph by default or when preserveId is true', () => {
|
||||
const existingIds = new Set<string>();
|
||||
const paragraph = { startIndex: 5, paragraphId: 'para_keep' };
|
||||
|
||||
const defaultClone = cloneParagraphWithId(paragraph, existingIds);
|
||||
const explicitClone = cloneParagraphWithId({ ...paragraph, paragraphId: 'para_keep_explicit' }, existingIds, true);
|
||||
|
||||
expect(defaultClone).not.toBe(paragraph);
|
||||
expect(defaultClone.paragraphId).toBe('para_keep');
|
||||
expect(explicitClone.paragraphId).toBe('para_keep_explicit');
|
||||
expect(existingIds.has('para_keep')).toBe(true);
|
||||
expect(existingIds.has('para_keep_explicit')).toBe(true);
|
||||
});
|
||||
|
||||
it('regenerates a paragraph id when preserving would duplicate an existing id', () => {
|
||||
const existingIds = new Set<string>(['para_duplicate']);
|
||||
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'para_duplicate' }, existingIds);
|
||||
|
||||
expect(cloned.paragraphId).not.toBe('para_duplicate');
|
||||
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
|
||||
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
|
||||
});
|
||||
|
||||
it('regenerates invalid paragraph ids when cloning a paragraph', () => {
|
||||
const existingIds = new Set<string>();
|
||||
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'invalid' }, existingIds);
|
||||
|
||||
expect(cloned.paragraphId).not.toBe('invalid');
|
||||
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
|
||||
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
|
||||
});
|
||||
|
||||
it('regenerates a paragraph id when preserveId is false', () => {
|
||||
const existingIds = new Set<string>();
|
||||
const cloned = cloneParagraphWithId({ startIndex: 5, paragraphId: 'para_replace' }, existingIds, false);
|
||||
|
||||
expect(cloned.paragraphId).not.toBe('para_replace');
|
||||
expect(cloned.paragraphId).toMatch(new RegExp(`^${PARAGRAPH_ID_PREFIX}`));
|
||||
expect(existingIds.has(cloned.paragraphId!)).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { resolveSectionHeaderFooterReference, resolveSectionHeaderFooterReferences } from '../section-header-footer';
|
||||
|
||||
describe('section header/footer references', () => {
|
||||
const documentStyle = { defaultHeaderId: 'header-global', defaultFooterId: 'footer-global' };
|
||||
const sections = [
|
||||
{ sectionId: 'section_1', startIndex: 3 },
|
||||
{ sectionId: 'section_2', startIndex: 7 },
|
||||
{ sectionId: 'section_3', startIndex: 11, defaultHeaderId: 'header-3' },
|
||||
{ sectionId: 'section_4', startIndex: 15 },
|
||||
];
|
||||
|
||||
it('uses document defaults only for the first section and inherits through later sections', () => {
|
||||
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 0, 'defaultHeaderId')).toEqual({
|
||||
segmentId: 'header-global',
|
||||
linkedToPrevious: false,
|
||||
});
|
||||
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 1, 'defaultHeaderId')).toEqual({
|
||||
segmentId: 'header-global',
|
||||
linkedToPrevious: true,
|
||||
sourceSectionId: undefined,
|
||||
});
|
||||
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 2, 'defaultHeaderId')).toEqual({
|
||||
segmentId: 'header-3',
|
||||
linkedToPrevious: false,
|
||||
sourceSectionId: 'section_3',
|
||||
});
|
||||
expect(resolveSectionHeaderFooterReference(documentStyle, sections, 3, 'defaultHeaderId')).toEqual({
|
||||
segmentId: 'header-3',
|
||||
linkedToPrevious: true,
|
||||
sourceSectionId: 'section_3',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves all six reference variants independently', () => {
|
||||
expect(resolveSectionHeaderFooterReferences(documentStyle, sections, 1)).toEqual({
|
||||
defaultHeaderId: 'header-global',
|
||||
defaultFooterId: 'footer-global',
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,119 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentBody } from '../../../types/interfaces/i-document-data';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CustomRangeType, DocStyleType } from '../../../types/interfaces/i-document-data';
|
||||
import { DocumentDataModel } from '../document-data-model';
|
||||
import { JSONX } from '../json-x/json-x';
|
||||
import { validateDocumentStructure } from '../text-x/structure-validator';
|
||||
import { TextX } from '../text-x/text-x';
|
||||
import { getRichTextEditPath } from '../text-x/utils';
|
||||
|
||||
function body(text: string): IDocumentBody {
|
||||
return {
|
||||
dataStream: `${text}\r\n`,
|
||||
customBlocks: [],
|
||||
customRanges: [],
|
||||
customDecorations: [],
|
||||
paragraphs: [{ startIndex: text.length, paragraphId: 'paragraph' }],
|
||||
sectionBreaks: [{ startIndex: text.length + 1, sectionId: 'section' }],
|
||||
};
|
||||
}
|
||||
|
||||
function createDocument(): DocumentDataModel {
|
||||
return new DocumentDataModel({
|
||||
id: 'doc',
|
||||
body: {
|
||||
...body('A\uFFFCB'),
|
||||
customRanges: [{ startIndex: 1, endIndex: 1, rangeId: 'ref', rangeType: CustomRangeType.FOOTNOTE, wholeEntity: true, properties: { noteId: 'note' } }],
|
||||
},
|
||||
notes: { note: { type: 'footnote' as const, noteId: 'note', body: body('Explanation') } },
|
||||
noteSettings: { footnote: { position: 'pageBottom', restart: 'eachSect', numberFormat: 'lowerRoman', startNumber: 3 } },
|
||||
});
|
||||
}
|
||||
|
||||
describe('document footnote segments', () => {
|
||||
it('inherits document styles and refreshes them without persisting duplicate styles in each note', () => {
|
||||
const document = createDocument();
|
||||
const jsonX = JSONX.getInstance();
|
||||
try {
|
||||
document.apply(jsonX.insertOp(['styles'], { FootnoteText: { name: 'Footnote Text', type: DocStyleType.paragraph, textStyle: { fs: 10 } } }));
|
||||
const note = document.noteModelMap.get('note');
|
||||
expect(note?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(10);
|
||||
document.apply(jsonX.replaceOp(['styles', 'FootnoteText', 'textStyle', 'fs'], 10, 12));
|
||||
expect(document.noteModelMap.get('note')).not.toBe(note);
|
||||
expect(document.noteModelMap.get('note')?.getSnapshot().styles?.FootnoteText.textStyle?.fs).toBe(12);
|
||||
document.updateDocumentStyle({ textStyle: { fs: 14 }, marginLeft: 40 });
|
||||
expect(document.noteModelMap.get('note')?.getDocumentStyle()).toMatchObject({
|
||||
textStyle: { fs: 14 },
|
||||
marginLeft: 40,
|
||||
});
|
||||
expect(Object.keys(document.getSnapshot().notes!.note)).toEqual(['type', 'noteId', 'body']);
|
||||
} finally {
|
||||
document.dispose();
|
||||
}
|
||||
});
|
||||
|
||||
it('edits note text through the rich-text path and restores it through undo', () => {
|
||||
const document = createDocument();
|
||||
const before = structuredClone(document.getSnapshot());
|
||||
const path = getRichTextEditPath(document, 'note');
|
||||
const edit = JSONX.getInstance().editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), path);
|
||||
const undo = JSONX.invertWithDoc(edit, before);
|
||||
document.apply(edit);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
|
||||
expect(document.getBody()).toEqual(before.body);
|
||||
document.apply(undo);
|
||||
expect(document.getSnapshot()).toEqual(before);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')?.getBody()?.dataStream).toBe('Explanation\r\n');
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('preserves the note identity when text before its reference is edited', () => {
|
||||
const document = createDocument();
|
||||
const edit = JSONX.getInstance().editOp(new TextX().insert(2, { dataStream: '前文' }).serialize(), ['body']);
|
||||
document.apply(edit);
|
||||
expect(document.getBody()?.customRanges?.[0]).toMatchObject({ startIndex: 3, endIndex: 3, properties: { noteId: 'note' } });
|
||||
const restored = new DocumentDataModel(document.getSnapshot());
|
||||
expect(restored.getSelfOrHeaderFooterModel('note')?.getBody()).toEqual(document.getSnapshot().notes?.note.body);
|
||||
expect(restored.getSnapshot().noteSettings?.footnote).toEqual(document.getSnapshot().noteSettings?.footnote);
|
||||
expect(validateDocumentStructure(restored.getSnapshot())).toEqual([]);
|
||||
restored.dispose();
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('keeps unrelated note models when one note is edited', () => {
|
||||
const document = createDocument();
|
||||
const jsonX = JSONX.getInstance();
|
||||
document.apply(jsonX.insertOp(['notes', 'other'], { type: 'footnote' as const, noteId: 'other', body: body('Other explanation') }));
|
||||
const other = document.noteModelMap.get('other');
|
||||
const edit = jsonX.editOp(new TextX().insert(4, { dataStream: 'New ' }).serialize(), ['notes', 'note', 'body']);
|
||||
document.apply(edit);
|
||||
expect(document.noteModelMap.get('other')).toBe(other);
|
||||
expect(document.noteModelMap.get('note')?.getBody()?.dataStream).toBe('New Explanation\r\n');
|
||||
document.dispose();
|
||||
});
|
||||
|
||||
it('removes a disposed note segment from the model after a persisted deletion', () => {
|
||||
const document = createDocument();
|
||||
const edit = JSONX.getInstance().removeOp(['notes', 'note'], document.getSnapshot().notes?.note);
|
||||
document.apply(edit);
|
||||
expect(document.getSelfOrHeaderFooterModel('note')).toBeNull();
|
||||
expect(validateDocumentStructure(document.getSnapshot()).map((issue) => issue.code)).toContain('missing-note');
|
||||
document.dispose();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* Copyright 2023-present DreamNum Co., Ltd.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import type { IDocumentData } from '../../../types/interfaces/i-document-data';
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { DOCS_NORMAL_EDITOR_UNIT_ID_KEY } from '../../../common/const';
|
||||
import { BooleanNumber, HorizontalAlign, TextDecoration, TextDirection } from '../../../types/enum/text-style';
|
||||
import { DocumentFlavor } from '../../../types/interfaces/i-document-data';
|
||||
import { DocumentDataModel } from '../document-data-model';
|
||||
import { JSONX } from '../json-x/json-x';
|
||||
import { ParagraphStyleBuilder, RichTextBuilder, TextDecorationBuilder, TextStyleBuilder } from '../rich-text-builder';
|
||||
import { DataStreamTreeTokenType } from '../types';
|
||||
|
||||
function createDocSnapshot(id = 'doc-main'): IDocumentData {
|
||||
return {
|
||||
id,
|
||||
title: 'Main Doc',
|
||||
documentStyle: {
|
||||
pageSize: { width: 300, height: 400 },
|
||||
marginTop: 1,
|
||||
marginBottom: 2,
|
||||
marginLeft: 3,
|
||||
marginRight: 4,
|
||||
},
|
||||
body: {
|
||||
dataStream: 'Hello World\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_1' }],
|
||||
textRuns: [{ st: 0, ed: 5, ts: { ff: 'Arial', fs: 12 } }],
|
||||
customRanges: [],
|
||||
customDecorations: [],
|
||||
},
|
||||
headers: {
|
||||
header1: {
|
||||
headerId: 'header1',
|
||||
body: {
|
||||
dataStream: 'Header\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_2' }],
|
||||
textRuns: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
footers: {
|
||||
footer1: {
|
||||
footerId: 'footer1',
|
||||
body: {
|
||||
dataStream: 'Footer\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_3' }],
|
||||
textRuns: [],
|
||||
},
|
||||
},
|
||||
},
|
||||
drawings: {
|
||||
drawing1: {
|
||||
drawingId: 'drawing1',
|
||||
docTransform: {
|
||||
size: { width: 10, height: 10 },
|
||||
positionH: { posOffset: 0 },
|
||||
positionV: { posOffset: 0 },
|
||||
},
|
||||
},
|
||||
} as any,
|
||||
drawingsOrder: ['drawing1'],
|
||||
};
|
||||
}
|
||||
|
||||
describe('DocumentDataModel + RichTextBuilder integration', () => {
|
||||
it('should hydrate missing default fields for partial normal document snapshots', () => {
|
||||
const model = new DocumentDataModel({ id: 'partial-doc', title: 'Partial Doc' });
|
||||
|
||||
expect(model.getUnitId()).toBe('partial-doc');
|
||||
expect(model.getTitle()).toBe('Partial Doc');
|
||||
expect(model.getBody()?.dataStream).toBe('\r\n');
|
||||
expect(model.getSnapshot().headers).toEqual({});
|
||||
expect(model.getSnapshot().footers).toEqual({});
|
||||
expect(model.getDrawings()).toEqual({});
|
||||
expect(model.getDrawingsOrder()).toEqual([]);
|
||||
expect(model.getSettings()).toEqual({});
|
||||
expect(model.getSnapshot().tableSource).toEqual({});
|
||||
expect(model.getDocumentStyle().documentFlavor).toBe(DocumentFlavor.MODERN);
|
||||
expect(model.getDocumentStyle().pageSize).toBeDefined();
|
||||
expect(() => model.updateDocumentDataMargin({ t: 10 })).not.toThrow();
|
||||
expect(model.getDocumentStyle().marginTop).toBe(10);
|
||||
|
||||
model.reset({ id: 'partial-doc', title: 'Reset Partial Doc' });
|
||||
|
||||
expect(model.getTitle()).toBe('Reset Partial Doc');
|
||||
expect(model.getBody()?.dataStream).toBe('\r\n');
|
||||
expect(model.getSnapshot().headers).toEqual({});
|
||||
expect(model.getDrawings()).toEqual({});
|
||||
expect(model.getSettings()).toEqual({});
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it('should normalize missing and duplicate legacy section ids at the model boundary', () => {
|
||||
const model = new DocumentDataModel({
|
||||
id: 'legacy-section-ids',
|
||||
body: {
|
||||
dataStream: 'A\nB\n',
|
||||
sectionBreaks: [
|
||||
{ startIndex: 1, sectionId: '' },
|
||||
{ startIndex: 3, sectionId: '' },
|
||||
],
|
||||
},
|
||||
});
|
||||
const sectionIds = model.getBody()?.sectionBreaks?.map((sectionBreak) => sectionBreak.sectionId) ?? [];
|
||||
|
||||
expect(sectionIds).toHaveLength(2);
|
||||
expect(sectionIds.every(Boolean)).toBe(true);
|
||||
expect(new Set(sectionIds).size).toBe(2);
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it('should keep internal editor document snapshots shallow', () => {
|
||||
const model = new DocumentDataModel({
|
||||
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
title: 'Editor Doc',
|
||||
documentStyle: {},
|
||||
body: {
|
||||
dataStream: 'Draft\r\n',
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getUnitId()).toBe(DOCS_NORMAL_EDITOR_UNIT_ID_KEY);
|
||||
expect(model.getTitle()).toBe('Editor Doc');
|
||||
expect(model.getBody()?.dataStream).toBe('Draft\r\n');
|
||||
expect(model.getSnapshot().headers).toBeUndefined();
|
||||
expect(model.getSnapshot().footers).toBeUndefined();
|
||||
expect(model.getDrawings()).toBeUndefined();
|
||||
expect(model.getDrawingsOrder()).toBeUndefined();
|
||||
expect(model.getSettings()).toBeUndefined();
|
||||
expect(model.getSnapshot().tableSource).toBeUndefined();
|
||||
expect(model.getDocumentStyle()).toEqual({});
|
||||
|
||||
model.reset({
|
||||
id: DOCS_NORMAL_EDITOR_UNIT_ID_KEY,
|
||||
title: 'Reset Editor Doc',
|
||||
documentStyle: {},
|
||||
body: {
|
||||
dataStream: 'Reset\r\n',
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getTitle()).toBe('Reset Editor Doc');
|
||||
expect(model.getBody()?.dataStream).toBe('Reset\r\n');
|
||||
expect(model.getSnapshot().headers).toBeUndefined();
|
||||
expect(model.getSettings()).toBeUndefined();
|
||||
expect(model.getDocumentStyle()).toEqual({});
|
||||
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it('should perform typical editing operations and reflect them in the document snapshot', () => {
|
||||
const model = new DocumentDataModel(createDocSnapshot());
|
||||
expect(model.getUnitId()).toBe('doc-main');
|
||||
expect(model.getTitle()).toBe('Main Doc');
|
||||
|
||||
model.setName('Renamed');
|
||||
expect(model.getTitle()).toBe('Renamed');
|
||||
|
||||
model.updateDocumentDataMargin({ t: 10, l: 20 });
|
||||
model.updateDocumentDataPageSize(500, 600);
|
||||
expect(model.getDocumentStyle().marginTop).toBe(10);
|
||||
expect(model.getDocumentStyle().marginLeft).toBe(20);
|
||||
expect(model.getDocumentStyle().pageSize).toEqual({ width: 500, height: 600 });
|
||||
|
||||
model.updateDocumentRenderConfig({
|
||||
locale: 'enUS',
|
||||
} as any);
|
||||
model.updateDocumentStyle({
|
||||
renderConfig: { direction: TextDirection.LEFT_TO_RIGHT },
|
||||
} as any);
|
||||
|
||||
model.updateDrawing('drawing1', { width: 100, height: 50, left: 12, top: 34 });
|
||||
expect(model.getDrawings()?.drawing1.docTransform.size).toEqual({ width: 100, height: 50 });
|
||||
|
||||
model.setZoomRatio(1.5);
|
||||
model.setDisabled(true);
|
||||
expect(model.zoomRatio).toBe(1.5);
|
||||
expect(model.getDisabled()).toBe(true);
|
||||
|
||||
const richText = RichTextBuilder.create(RichTextBuilder.newEmptyData());
|
||||
richText
|
||||
.insertText('Hello')
|
||||
.insertParagraph(
|
||||
ParagraphStyleBuilder.create({
|
||||
horizontalAlign: HorizontalAlign.CENTER,
|
||||
direction: TextDirection.RIGHT_TO_LEFT,
|
||||
})
|
||||
)
|
||||
.insertLink(' Univer', 'https://univer.ai')
|
||||
.setStyle(0, 5, TextStyleBuilder.create({ ff: 'Inter', fs: 14, bl: BooleanNumber.TRUE }))
|
||||
.setLink(0, 5, 'https://example.com');
|
||||
|
||||
const firstLinkId = richText.getLinks()[0].rangeId;
|
||||
richText.updateLink(firstLinkId, 'https://updated.example.com');
|
||||
|
||||
expect(richText.toPlainText()).toContain('Hello');
|
||||
expect(richText.getLinks()).toHaveLength(2);
|
||||
|
||||
const sliced = richText.slice(0, 5);
|
||||
expect(sliced.toPlainText()).toContain('Hello');
|
||||
|
||||
const decoration = TextDecorationBuilder.create({ s: BooleanNumber.TRUE })
|
||||
.setLineType(TextDecoration.SINGLE)
|
||||
.setFollowFontColor(false)
|
||||
.build();
|
||||
|
||||
const styled = new TextStyleBuilder({}).setUnderline(TextDecorationBuilder.create(decoration)).build();
|
||||
expect(styled.ul?.s).toBe(BooleanNumber.TRUE);
|
||||
|
||||
const docData = richText.getData();
|
||||
expect(docData.body?.dataStream).toContain('Hello');
|
||||
|
||||
const jsonx = JSONX.getInstance();
|
||||
model.apply(jsonx.replaceOp(['title'], 'Renamed', 'Applied Title') as any);
|
||||
model.apply(jsonx.replaceOp(['settings', 'zoomRatio'], 1.5, 2) as any);
|
||||
expect(model.getTitle()).toBe('Applied Title');
|
||||
|
||||
expect(model.getPlainText()).toContain('Hello World');
|
||||
expect(model.sliceBody(0, 5)?.dataStream).toContain('Hello');
|
||||
|
||||
const partialParagraphModel = new DocumentDataModel({
|
||||
id: 'partial-paragraph',
|
||||
title: 'Partial Paragraph',
|
||||
documentStyle: {},
|
||||
body: {
|
||||
dataStream: 'First\rSecond\r',
|
||||
paragraphs: [
|
||||
{ startIndex: 5, paragraphId: 'para_fixture_4', paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER } },
|
||||
{ startIndex: 12, paragraphId: 'para_fixture_5', paragraphStyle: { textStyle: { fs: 18 } } },
|
||||
],
|
||||
},
|
||||
});
|
||||
expect(partialParagraphModel.sliceBody(0, 5)?.paragraphs?.[0]).toMatchObject({
|
||||
startIndex: 5,
|
||||
paragraphStyle: { horizontalAlign: HorizontalAlign.CENTER },
|
||||
});
|
||||
expect(partialParagraphModel.sliceBody(6, 12)?.paragraphs?.[0]).toMatchObject({
|
||||
startIndex: 6,
|
||||
paragraphStyle: { textStyle: { fs: 18 } },
|
||||
});
|
||||
partialParagraphModel.dispose();
|
||||
|
||||
const tableStream = [
|
||||
DataStreamTreeTokenType.TABLE_START,
|
||||
DataStreamTreeTokenType.TABLE_ROW_START,
|
||||
DataStreamTreeTokenType.TABLE_CELL_START,
|
||||
'Cell\r\n',
|
||||
DataStreamTreeTokenType.TABLE_CELL_END,
|
||||
DataStreamTreeTokenType.TABLE_ROW_END,
|
||||
DataStreamTreeTokenType.TABLE_END,
|
||||
].join('');
|
||||
const partialTableModel = new DocumentDataModel({
|
||||
id: 'partial-table',
|
||||
title: 'Partial Table',
|
||||
documentStyle: {},
|
||||
body: {
|
||||
dataStream: tableStream,
|
||||
tables: [{ startIndex: 0, endIndex: tableStream.length, tableId: 'table-1' }],
|
||||
paragraphs: [{ startIndex: 7, paragraphId: 'para_fixture_6' }],
|
||||
},
|
||||
});
|
||||
expect(partialTableModel.sliceBody(1, tableStream.length - 1)?.tables?.[0]).toMatchObject({
|
||||
startIndex: 0,
|
||||
endIndex: tableStream.length - 2,
|
||||
tableId: 'table-1',
|
||||
});
|
||||
partialTableModel.dispose();
|
||||
|
||||
model.resetDrawing({} as any, []);
|
||||
expect(model.getDrawingsOrder()).toEqual([]);
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it('should manage reset, noop apply and header-footer reinitialization for realistic document state changes', () => {
|
||||
const snapshot = createDocSnapshot('doc-reset');
|
||||
const model = new DocumentDataModel(snapshot);
|
||||
const jsonx = JSONX.getInstance();
|
||||
const initialChangeCount = model.change$.getValue();
|
||||
|
||||
expect(model.getCustomRanges()).toEqual([]);
|
||||
expect(model.getCustomDecorations()).toEqual([]);
|
||||
expect(model.getSettings()).toEqual({});
|
||||
expect(model.apply(null as never)).toBeUndefined();
|
||||
expect(model.change$.getValue()).toBe(initialChangeCount);
|
||||
|
||||
model.setZoomRatio(2);
|
||||
expect(model.getSettings()).toEqual({ zoomRatio: 2 });
|
||||
|
||||
model.apply(jsonx.replaceOp(['headers'], model.getSnapshot().headers, {
|
||||
...model.getSnapshot().headers,
|
||||
header2: {
|
||||
headerId: 'header2',
|
||||
body: {
|
||||
dataStream: 'Another Header\r\n',
|
||||
paragraphs: [{ startIndex: 0, paragraphId: 'para_fixture_7' }],
|
||||
},
|
||||
},
|
||||
}) as never);
|
||||
|
||||
expect(model.getSnapshot().headers?.header2?.body.dataStream).toBe('Another Header\r\n');
|
||||
expect(model.change$.getValue()).toBeGreaterThan(initialChangeCount);
|
||||
|
||||
expect(() => model.reset({ id: 'other-doc' })).toThrow('Cannot reset a document model with a different unit id!');
|
||||
|
||||
model.reset({
|
||||
id: 'doc-reset',
|
||||
title: 'Reset Title',
|
||||
body: {
|
||||
dataStream: 'Reset\r\n',
|
||||
paragraphs: [{ startIndex: 5, paragraphId: 'para_fixture_8' }],
|
||||
},
|
||||
documentStyle: {},
|
||||
});
|
||||
|
||||
expect(model.getTitle()).toBe('Reset Title');
|
||||
expect(model.getBody()?.dataStream).toBe('Reset\r\n');
|
||||
expect(model.getUnitId()).toBe('doc-reset');
|
||||
expect(model.sliceBody(0, 5)?.dataStream).toContain('Reset');
|
||||
model.dispose();
|
||||
});
|
||||
|
||||
it('normalizes legacy section metadata that follows page breaks when loading a document', () => {
|
||||
const model = new DocumentDataModel({
|
||||
id: 'legacy-page-breaks',
|
||||
body: {
|
||||
dataStream: 'Before\r\n\fAfter\r\n\fTail\r\n\n',
|
||||
paragraphs: [
|
||||
{ startIndex: 6, paragraphId: 'before' },
|
||||
{ startIndex: 14, paragraphId: 'after' },
|
||||
{ startIndex: 21, paragraphId: 'tail' },
|
||||
],
|
||||
sectionBreaks: [
|
||||
{ startIndex: 9, sectionId: 'legacy-1' },
|
||||
{ startIndex: 17, sectionId: 'legacy-2' },
|
||||
{ startIndex: 23, sectionId: 'final' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(model.getBody()?.sectionBreaks).toEqual([{ startIndex: 23, sectionId: 'final' }]);
|
||||
model.dispose();
|
||||
});
|
||||
});
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user