Henry Heino 2025-04-19 17:17:24 +03:00 committed by GitHub
commit 12edca33c8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 175 additions and 34 deletions

View File

@ -1427,6 +1427,7 @@ packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.report.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
packages/lib/services/synchronizer/Synchronizer.sharing.test.js

1
.gitignore vendored
View File

@ -1401,6 +1401,7 @@ packages/lib/services/synchronizer/Synchronizer.basics.test.js
packages/lib/services/synchronizer/Synchronizer.conflicts.test.js
packages/lib/services/synchronizer/Synchronizer.e2ee.test.js
packages/lib/services/synchronizer/Synchronizer.ppk.test.js
packages/lib/services/synchronizer/Synchronizer.report.test.js
packages/lib/services/synchronizer/Synchronizer.resources.test.js
packages/lib/services/synchronizer/Synchronizer.revisions.test.js
packages/lib/services/synchronizer/Synchronizer.sharing.test.js

View File

@ -1,18 +1,11 @@
import reducer from '@joplin/lib/reducer';
import { createStore } from 'redux';
import { createReduxStore } from '@joplin/lib/testing/test-utils';
import appDefaultState from '../appDefaultState';
import Setting from '@joplin/lib/models/Setting';
import { AppState } from '../types';
const testReducer = (state: AppState|undefined, action: unknown) => {
state ??= {
...appDefaultState,
settings: Setting.toPlainObject(),
};
return reducer(state, action);
};
const createMockReduxStore = () => {
return createStore(testReducer);
return createReduxStore({
...appDefaultState,
settings: Setting.toPlainObject(),
});
};
export default createMockReduxStore;

View File

@ -30,7 +30,7 @@ import handleConflictAction from './services/synchronizer/utils/handleConflictAc
import resourceRemotePath from './services/synchronizer/utils/resourceRemotePath';
import syncDeleteStep from './services/synchronizer/utils/syncDeleteStep';
import { ErrorCode } from './errors';
import { SyncAction } from './services/synchronizer/utils/types';
import { SyncAction, SyncReport, ItemCountPerType } from './services/synchronizer/utils/types';
import checkDisabledSyncItemsNotification from './services/synchronizer/utils/checkDisabledSyncItemsNotification';
const { sprintf } = require('sprintf-js');
const { Dirnames } = require('./services/synchronizer/utils/types');
@ -82,8 +82,7 @@ export default class Synchronizer {
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
private onProgress_: Function;
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
private progressReport_: any = {};
private progressReport_: SyncReport = {};
// eslint-disable-next-line @typescript-eslint/ban-types -- Old code before rule was applied
public dispatch: Function;
@ -193,15 +192,62 @@ export default class Synchronizer {
return `${duration}ms`;
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public static reportToLines(report: any) {
public static reportToLines(report: SyncReport) {
const formatItemCounts = (counts: ItemCountPerType) => {
const modifiedItemNames: string[] = [];
let hasOther = false;
let sum = 0;
for (const [key, value] of Object.entries(counts)) {
if (value) {
sum += value;
if (key === 'Revision') {
modifiedItemNames.push(_('note history'));
} else if (key === 'Note') {
modifiedItemNames.push(_('notes'));
} else if (key === 'Resource') {
modifiedItemNames.push(_('resources'));
} else if (key === 'Folder') {
modifiedItemNames.push(_('notebooks'));
} else {
hasOther = true;
}
}
}
// In some cases, no type information is available (e.g. when creating local items).
// In these cases, avoid logging "other", because that might be inaccurate.
if (hasOther && modifiedItemNames.length > 0) {
modifiedItemNames.push(_('other'));
}
if (modifiedItemNames.length > 0) {
return _('%d (%s)', sum, modifiedItemNames.join(', '));
} else {
return _('%d', sum);
}
};
const lines = [];
if (report.createLocal) lines.push(_('Created local items: %d.', report.createLocal));
if (report.updateLocal) lines.push(_('Updated local items: %d.', report.updateLocal));
if (report.createRemote) lines.push(_('Created remote items: %d.', report.createRemote));
if (report.updateRemote) lines.push(_('Updated remote items: %d.', report.updateRemote));
if (report.deleteLocal) lines.push(_('Deleted local items: %d.', report.deleteLocal));
if (report.deleteRemote) lines.push(_('Deleted remote items: %d.', report.deleteRemote));
if (report.createLocal) {
lines.push(_('Created local: %s.', formatItemCounts(report.createLocal)));
}
if (report.updateLocal) {
lines.push(_('Updated local: %s.', formatItemCounts(report.updateLocal)));
}
if (report.createRemote) {
lines.push(_('Created remote: %s.', formatItemCounts(report.createRemote)));
}
if (report.updateRemote) {
lines.push(_('Updated remote: %s.', formatItemCounts(report.updateRemote)));
}
if (report.deleteLocal) {
lines.push(_('Deleted local: %s.', formatItemCounts(report.deleteLocal)));
}
if (report.deleteRemote) {
lines.push(_('Deleted remote: %s.', formatItemCounts(report.deleteRemote)));
}
if (report.fetchingTotal && report.fetchingProcessed) lines.push(_('Fetched items: %d/%d.', report.fetchingProcessed, report.fetchingTotal));
if (report.cancelling && !report.completedTime) lines.push(_('Cancelling...'));
if (report.completedTime) lines.push(_('Completed: %s (%s)', time.formatMsToLocal(report.completedTime), this.completionTime(report)));
@ -216,10 +262,13 @@ export default class Synchronizer {
line.push(action);
if (message) line.push(message);
let type = local && local.type_ ? local.type_ : null;
if (!type) type = remote && remote.type_ ? remote.type_ : null;
const type: ModelType|null = local?.type_ ?? remote?.type_ ?? null;
if (type) line.push(BaseItem.modelTypeToClassName(type));
let modelName = 'unknown';
if (type) {
modelName = BaseItem.modelTypeToClassName(type);
line.push(modelName);
}
if (local) {
const s = [];
@ -241,8 +290,19 @@ export default class Synchronizer {
if (!['fetchingProcessed', 'fetchingTotal'].includes(action)) syncDebugLog.info(line.join(': '));
if (!this.progressReport_[action]) this.progressReport_[action] = 0;
this.progressReport_[action] += actionCount;
// Actions that are categorized by per-item-type
const isItemAction = (testAction: string): testAction is SyncAction => {
const syncActions: string[] = Object.values(SyncAction);
return syncActions.includes(testAction);
};
if (isItemAction(action)) {
this.progressReport_[action] = { ...this.progressReport_[action] };
this.progressReport_[action][modelName] ??= 0;
this.progressReport_[action][modelName] += actionCount;
} else if (action === 'fetchingProcessed' || action === 'fetchingTotal') {
this.progressReport_[action] ??= 0;
this.progressReport_[action] += actionCount;
}
this.progressReport_.state = this.state();
this.onProgress_(this.progressReport_);
@ -251,13 +311,14 @@ export default class Synchronizer {
// for this but for now this simple fix will do.
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
const reportCopy: any = {};
for (const n in this.progressReport_) reportCopy[n] = this.progressReport_[n];
for (const [key, value] of Object.entries(this.progressReport_)) {
reportCopy[key] = value;
}
if (reportCopy.errors) reportCopy.errors = this.progressReport_.errors.slice();
this.dispatch({ type: 'SYNC_REPORT_UPDATE', report: reportCopy });
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
public async logSyncSummary(report: any) {
public async logSyncSummary(report: SyncReport) {
logger.info('Operations completed: ');
for (const n in report) {
if (!report.hasOwnProperty(n)) continue;
@ -267,7 +328,8 @@ export default class Synchronizer {
if (n === 'state') continue;
if (n === 'startTime') continue;
if (n === 'completedTime') continue;
logger.info(`${n}: ${report[n] ? report[n] : '-'}`);
const key = n as keyof typeof report;
logger.info(`${n}: ${report[key] ? report[key] : '-'}`);
}
const folderCount = await Folder.count();
const noteCount = await Note.count();

View File

@ -0,0 +1,54 @@
import { Store } from 'redux';
import { State } from '../../reducer';
import Folder from '../../models/Folder';
import Note from '../../models/Note';
import Synchronizer from '../../Synchronizer';
import { createReduxStore, setupDatabaseAndSynchronizer, switchClient, synchronizer, synchronizerStart } from '../../testing/test-utils';
let appStoreClient2: Store<State>;
const getClient2SyncReport = () => {
return appStoreClient2.getState().syncReport;
};
describe('Synchronizer.report', () => {
beforeEach(async () => {
await setupDatabaseAndSynchronizer(1);
await setupDatabaseAndSynchronizer(2);
await switchClient(1);
appStoreClient2 = createReduxStore();
synchronizer(2).dispatch = appStoreClient2.dispatch;
});
test('should list the different kinds of items that were deleted', async () => {
const folder = await Folder.save({ title: 'Test folder' });
await Note.save({ title: 'Note 1', parent_id: folder.id });
const note2 = await Note.save({ title: 'Note 2' });
// Ensure that client 2 creates the items
await synchronizerStart();
await switchClient(2);
await synchronizerStart();
await Note.delete(note2.id, { toTrash: false });
await synchronizerStart();
// Deleting a remote item: Should list item types
expect(Synchronizer.reportToLines(getClient2SyncReport())[0]).toBe(
'Deleted remote: 1 (notes).',
);
// Delete a remote folder
await switchClient(1);
await Folder.delete(folder.id);
await synchronizerStart();
await switchClient(2);
// Deleting local items: Sync report should include type descriptions:
await synchronizerStart();
expect(Synchronizer.reportToLines(getClient2SyncReport())[0]).toBe(
'Deleted local: 2 (notes, notebooks).',
);
});
});

View File

@ -24,7 +24,7 @@ export default async (syncTargetId: number, cancelling: boolean, logSyncOperatio
await apiCall('delete', remoteContentPath);
}
logSyncOperation(SyncAction.DeleteRemote, null, { id: item.item_id }, 'local has been deleted');
logSyncOperation(SyncAction.DeleteRemote, null, { type_: item.item_type, id: item.item_id }, 'local has been deleted');
} catch (error) {
if (error.code === 'isReadOnly') {
let remoteContent = await apiCall('get', path);

View File

@ -18,6 +18,25 @@ export enum SyncAction {
DeleteLocal = 'deleteLocal',
}
export interface ItemCountPerType {
[modelType: string]: number;
}
type SyncReportItemSection = {
[action in SyncAction]?: ItemCountPerType;
};
export type SyncReport = SyncReportItemSection & {
fetchingTotal?: number;
fetchingProcessed?: number;
state?: string;
cancelling?: boolean;
startTime?: number;
completedTime?: number;
errors?: string[];
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
export type LogSyncOperationFunction = (action: SyncAction, local?: any, remote?: RemoteItem, message?: string, actionCount?: number)=> void;

View File

@ -67,7 +67,8 @@ import OcrDriverTesseract from '../services/ocr/drivers/OcrDriverTesseract';
import OcrService from '../services/ocr/OcrService';
import { createWorker } from 'tesseract.js';
import { reg } from '../registry';
import { Store } from 'redux';
import { createStore, Store } from 'redux';
import reducer, { defaultState as defaultAppState, State as AppState } from '../reducer';
// Each suite has its own separate data and temp directory so that multiple
// suites can be run at the same time. suiteName is what is used to
@ -454,6 +455,16 @@ const createNoteAndResource = async (options: CreateNoteAndResourceOptions = nul
return { note, resource };
};
export const createReduxStore = <StateType extends AppState> (
defaultState: StateType = defaultAppState as StateType,
) => {
const mockReducer = (state: AppState = defaultState, action: unknown) => {
return reducer(state, action);
};
return createStore(mockReducer) as Store<StateType>;
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- Old code before rule was applied
async function setupDatabaseAndSynchronizer(id: number, options: any = null) {
if (id === null) id = currentClient_;