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

This commit is contained in:
upstream-mirror
2026-09-16 05:09:45 +05:00
commit bcf6ef63ad
7118 changed files with 1236854 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
# @univerjs/action-recorder
[![npm version](https://img.shields.io/npm/v/@univerjs/action-recorder?style=flat-square)](https://npmjs.com/package/@univerjs/action-recorder)
[![license](https://img.shields.io/npm/l/@univerjs/action-recorder?style=flat-square)](https://npmjs.com/package/@univerjs/action-recorder)
[![downloads](https://img.shields.io/npm/dm/@univerjs/action-recorder?style=flat-square)](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)

View 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"
}
}

View File

@@ -0,0 +1 @@
export { default } from '@univerjs-infra/shared/postcss';

View File

@@ -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([]);
});
});

View File

@@ -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;
},
};

View File

@@ -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;
},
};

View File

@@ -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);
});
});

View 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 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;
},
};

View 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 = {};

View File

@@ -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
}
}

View File

@@ -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,
}));
}
}

View File

@@ -0,0 +1,2 @@
@tailwind components;
@tailwind utilities;

View 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';

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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>;

View 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;

View 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;

View 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;

View 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;

View 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,
},
},
},
};

View 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);
}
}

View File

@@ -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);
});
});

View File

@@ -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',
},
},
]);
});
});

View 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();
}
}

View 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;
}

View 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>
);
}

View File

@@ -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',
},
},
]);
});
});

View 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;

View File

@@ -0,0 +1,8 @@
{
"extends": "@univerjs-infra/shared/tsconfigs/base",
"compilerOptions": {
"rootDir": "src",
"outDir": "lib/types"
},
"include": ["src"]
}

View 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"
]
}

View File

@@ -0,0 +1,3 @@
import createConfig from '@univerjs-infra/shared/vitest';
export default createConfig();

39
packages/core/README.md Normal file
View File

@@ -0,0 +1,39 @@
# @univerjs/core
[![npm version](https://img.shields.io/npm/v/@univerjs/core?style=flat-square)](https://npmjs.com/package/@univerjs/core)
[![license](https://img.shields.io/npm/l/@univerjs/core?style=flat-square)](https://npmjs.com/package/@univerjs/core)
[![downloads](https://img.shields.io/npm/dm/@univerjs/core?style=flat-square)](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
View 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"
}
}

View 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();
});
});

View 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');
});
});

View 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');
});
});

View 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);
}

View 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,
};
}

View 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;
}

View 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';

View 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.`);
}
}
}

File diff suppressed because it is too large Load Diff

View 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);
});
});

View 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);
});
});

View 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']]);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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 }],
]);
});
});

View 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();
});
});

View 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'
);
});
});

View 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);
});
});

View 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];
}

View 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 };

View 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());
}

View 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);
}

View 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);
}
});
}

View 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;
}

View 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;
}

View 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';
}
}

View 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;
}

View 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();
}
}

View 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)));
}

View 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)));
}

View 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;
}

View 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;

View 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';

View 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;
}
}

View 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];
}
}
}

View 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;
}

View 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;
}

View 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');
}

View 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;
}

View 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,
};

View 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);

View 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;
}
}

View 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();
};
}

View 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,
};
}

View 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;
}

View 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();
}

View 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;
}

View 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;
}
}

View 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);
});
});

View File

@@ -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',
});
});
});

View File

@@ -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();
});
});

View File

@@ -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