mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-17 16:37:43 +00:00
perf: reduce initial bundle size by lazy-loading sync providers, validation, and jira2md
This commit is contained in:
parent
6a682472c6
commit
beb5f3425c
15 changed files with 266 additions and 144 deletions
|
|
@ -50,7 +50,6 @@
|
|||
"spark-md5",
|
||||
"chrono-node",
|
||||
"dayjs",
|
||||
"jira2md",
|
||||
"query-string"
|
||||
]
|
||||
},
|
||||
|
|
|
|||
|
|
@ -248,7 +248,6 @@
|
|||
"jasmine-core": "^5.13.0",
|
||||
"jasmine-marbles": "^0.9.2",
|
||||
"jasmine-spec-reporter": "~7.0.0",
|
||||
"jira2md": "git+https://github.com/johannesjo/J2M.git",
|
||||
"karma": "~6.4.2",
|
||||
"karma-chrome-launcher": "~3.2.0",
|
||||
"karma-cli": "^2.0.0",
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@ import { ConfigFormSection, SyncConfig } from '../global-config.model';
|
|||
import { SyncProviderId } from '../../../op-log/sync-providers/provider.const';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../../util/is-android-web-view';
|
||||
import { IS_ELECTRON } from '../../../app.constants';
|
||||
import { fileSyncDroid, fileSyncElectron } from '../../../op-log/model/model-config';
|
||||
import {
|
||||
loadSyncProviders,
|
||||
LocalFileSyncPicker,
|
||||
} from '../../../op-log/sync-providers/sync-providers.factory';
|
||||
import { FormlyFieldConfig } from '@ngx-formly/core';
|
||||
import { IS_NATIVE_PLATFORM } from '../../../util/is-native-platform';
|
||||
import { SUPER_SYNC_DEFAULT_BASE_URL } from '../../../op-log/sync-providers/super-sync/super-sync.model';
|
||||
|
|
@ -159,8 +162,12 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
key: 'syncFolderPath',
|
||||
templateOptions: {
|
||||
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
|
||||
onClick: () => {
|
||||
return fileSyncElectron.pickDirectory();
|
||||
onClick: async () => {
|
||||
const providers = await loadSyncProviders();
|
||||
const localProvider = providers.find(
|
||||
(p) => p.id === SyncProviderId.LocalFile,
|
||||
);
|
||||
return (localProvider as LocalFileSyncPicker | undefined)?.pickDirectory();
|
||||
},
|
||||
},
|
||||
expressions: {
|
||||
|
|
@ -189,9 +196,13 @@ export const SYNC_FORM: ConfigFormSection<SyncConfig> = {
|
|||
key: 'safFolderUri',
|
||||
templateOptions: {
|
||||
text: T.F.SYNC.FORM.LOCAL_FILE.L_SYNC_FOLDER_PATH,
|
||||
onClick: () => {
|
||||
onClick: async () => {
|
||||
// NOTE: this actually sets the value in the model
|
||||
return fileSyncDroid.setupSaf();
|
||||
const providers = await loadSyncProviders();
|
||||
const localProvider = providers.find(
|
||||
(p) => p.id === SyncProviderId.LocalFile,
|
||||
);
|
||||
return (localProvider as LocalFileSyncPicker | undefined)?.setupSaf();
|
||||
},
|
||||
},
|
||||
expressions: {
|
||||
|
|
|
|||
|
|
@ -123,7 +123,7 @@ export class DialogSyncInitialCfgComponent implements AfterViewInit {
|
|||
}
|
||||
|
||||
// Load the provider's stored configuration
|
||||
const provider = this._providerManager.getProviderById(providerId);
|
||||
const provider = await this._providerManager.getProviderById(providerId);
|
||||
if (!provider) {
|
||||
// Provider not yet configured, keep current form state
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -28,7 +28,9 @@ export class DropboxEffects {
|
|||
if (syncCfg.syncProvider !== SyncProviderId.Dropbox) {
|
||||
return;
|
||||
}
|
||||
const provider = this._providerManager.getProviderById(SyncProviderId.Dropbox);
|
||||
const provider = await this._providerManager.getProviderById(
|
||||
SyncProviderId.Dropbox,
|
||||
);
|
||||
if (!provider) {
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ export class SyncConfigService {
|
|||
syncProviderId?: SyncProviderId,
|
||||
): Promise<void> {
|
||||
const activeProvider = syncProviderId
|
||||
? this._providerManager.getProviderById(syncProviderId)
|
||||
? await this._providerManager.getProviderById(syncProviderId)
|
||||
: this._providerManager.getActiveProvider();
|
||||
if (!activeProvider) {
|
||||
// During initial sync setup, no provider exists yet to store the key.
|
||||
|
|
@ -283,7 +283,7 @@ export class SyncConfigService {
|
|||
// This ensures sync services see isEncryptionEnabled=true when SuperSync encryption is enabled
|
||||
// Note: We need to check the SAVED private config because Formly doesn't include hidden fields
|
||||
if (providerId === SyncProviderId.SuperSync) {
|
||||
const activeProvider = this._providerManager.getProviderById(providerId);
|
||||
const activeProvider = await this._providerManager.getProviderById(providerId);
|
||||
const savedPrivateCfg = activeProvider
|
||||
? await activeProvider.privateCfg.load()
|
||||
: null;
|
||||
|
|
@ -305,7 +305,7 @@ export class SyncConfigService {
|
|||
const prop = PROP_MAP_TO_FORM[providerId];
|
||||
|
||||
// Load existing config to preserve OAuth tokens and other settings
|
||||
const activeProvider = this._providerManager.getProviderById(providerId);
|
||||
const activeProvider = await this._providerManager.getProviderById(providerId);
|
||||
const oldConfig = activeProvider ? await activeProvider.privateCfg.load() : {};
|
||||
|
||||
// Form fields contain provider-specific settings, but Dropbox uses OAuth tokens
|
||||
|
|
|
|||
|
|
@ -627,7 +627,7 @@ export class SyncWrapperService {
|
|||
providerId: SyncProviderId,
|
||||
force = false,
|
||||
): Promise<{ wasConfigured: boolean }> {
|
||||
const provider = this._providerManager.getProviderById(providerId);
|
||||
const provider = await this._providerManager.getProviderById(providerId);
|
||||
|
||||
if (!provider) {
|
||||
return { wasConfigured: false };
|
||||
|
|
|
|||
|
|
@ -1,5 +1,4 @@
|
|||
import { AllModelData, ModelCfg } from '../core/types/sync.types';
|
||||
import { Dropbox } from '../sync-providers/file-based/dropbox/dropbox';
|
||||
import { ProjectState } from '../../features/project/project.model';
|
||||
import { MenuTreeState } from '../../features/menu-tree/store/menu-tree.model';
|
||||
import { GlobalConfigState } from '../../features/config/global-config.model';
|
||||
|
|
@ -28,14 +27,6 @@ import { initialTaskState } from '../../features/tasks/store/task.reducer';
|
|||
import { initialTagState } from '../../features/tag/store/tag.reducer';
|
||||
import { initialSimpleCounterState } from '../../features/simple-counter/store/simple-counter.reducer';
|
||||
import { initialTaskRepeatCfgState } from '../../features/task-repeat-cfg/store/task-repeat-cfg.reducer';
|
||||
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
|
||||
import { Webdav } from '../sync-providers/file-based/webdav/webdav';
|
||||
import { SuperSyncProvider } from '../sync-providers/super-sync/super-sync';
|
||||
import { LocalFileSyncElectron } from '../sync-providers/file-based/local-file/local-file-sync-electron';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { LocalFileSyncAndroid } from '../sync-providers/file-based/local-file/local-file-sync-android';
|
||||
import { environment } from '../../../environments/environment';
|
||||
import {
|
||||
ArchiveModel,
|
||||
TimeTrackingState,
|
||||
|
|
@ -183,17 +174,3 @@ export const getDefaultMainModelData = (): Partial<AppDataComplete> => {
|
|||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
export const fileSyncElectron = new LocalFileSyncElectron();
|
||||
export const fileSyncDroid = new LocalFileSyncAndroid();
|
||||
|
||||
export const SYNC_PROVIDERS = [
|
||||
new Dropbox({
|
||||
appKey: DROPBOX_APP_KEY,
|
||||
basePath: environment.production ? `/` : `/DEV/`,
|
||||
}),
|
||||
new Webdav(environment.production ? undefined : `/DEV`),
|
||||
new SuperSyncProvider(environment.production ? undefined : `/DEV`),
|
||||
...(IS_ELECTRON ? [fileSyncElectron] : []),
|
||||
...(IS_ANDROID_WEB_VIEW ? [fileSyncDroid] : []),
|
||||
];
|
||||
|
|
|
|||
|
|
@ -59,9 +59,8 @@ export {
|
|||
isFileSyncProvider,
|
||||
} from './sync-providers/provider.interface';
|
||||
|
||||
// Providers
|
||||
export { Dropbox } from './sync-providers/file-based/dropbox/dropbox';
|
||||
export { DropboxPrivateCfg } from './sync-providers/file-based/dropbox/dropbox';
|
||||
// Provider types
|
||||
export type { DropboxPrivateCfg } from './sync-providers/file-based/dropbox/dropbox';
|
||||
|
||||
// VectorClock from core
|
||||
export { VectorClock } from '../core/util/vector-clock';
|
||||
|
|
|
|||
|
|
@ -19,17 +19,7 @@ import {
|
|||
PrivateCfgByProviderId,
|
||||
CurrentProviderPrivateCfg,
|
||||
} from '../core/types/sync.types';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
// Import providers
|
||||
import { Dropbox } from './file-based/dropbox/dropbox';
|
||||
import { Webdav } from './file-based/webdav/webdav';
|
||||
import { SuperSyncProvider } from './super-sync/super-sync';
|
||||
import { LocalFileSyncElectron } from './file-based/local-file/local-file-sync-electron';
|
||||
import { LocalFileSyncAndroid } from './file-based/local-file/local-file-sync-android';
|
||||
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { loadSyncProviders } from './sync-providers.factory';
|
||||
|
||||
/**
|
||||
* Sync status change payload type
|
||||
|
|
@ -40,53 +30,10 @@ export type SyncStatusChangePayload =
|
|||
| 'IN_SYNC'
|
||||
| 'SYNCING';
|
||||
|
||||
/**
|
||||
* Array of all available sync providers
|
||||
* Cast to generic SyncProviderBase - each provider implements
|
||||
* a specific SyncProviderId but we store them in a generic array.
|
||||
*/
|
||||
const SYNC_PROVIDERS: SyncProviderBase<SyncProviderId>[] = [
|
||||
new Dropbox({
|
||||
appKey: DROPBOX_APP_KEY,
|
||||
basePath: environment.production ? `/` : `/DEV/`,
|
||||
}) as SyncProviderBase<SyncProviderId>,
|
||||
new Webdav(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
new SuperSyncProvider(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
...(IS_ELECTRON
|
||||
? [new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>]
|
||||
: []),
|
||||
...(IS_ANDROID_WEB_VIEW
|
||||
? [new LocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>]
|
||||
: []),
|
||||
];
|
||||
|
||||
/**
|
||||
* Service for managing sync providers.
|
||||
*
|
||||
* This service replaces the provider management parts of PfapiService.
|
||||
* It handles:
|
||||
* - Active provider selection based on user config
|
||||
* - Provider readiness state
|
||||
* - Encryption/compression configuration
|
||||
* - Provider credential management
|
||||
*
|
||||
* ## Usage
|
||||
* ```typescript
|
||||
* const manager = inject(SyncProviderManager);
|
||||
*
|
||||
* // Get active provider
|
||||
* const provider = manager.getActiveProvider();
|
||||
*
|
||||
* // Subscribe to provider readiness
|
||||
* manager.isProviderReady$.subscribe(ready => ...);
|
||||
*
|
||||
* // Get provider by ID
|
||||
* const dropbox = manager.getProviderById(SyncProviderId.Dropbox);
|
||||
* ```
|
||||
* Providers are lazily loaded on first use to reduce initial bundle size.
|
||||
*/
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
|
|
@ -95,6 +42,9 @@ export class SyncProviderManager {
|
|||
private _dataInitStateService = inject(DataInitStateService);
|
||||
private _store = inject(Store);
|
||||
|
||||
// Lazily loaded providers (cached after first load)
|
||||
private _providers: SyncProviderBase<SyncProviderId>[] | null = null;
|
||||
|
||||
// Current active provider
|
||||
private _activeProvider: SyncProviderBase<SyncProviderId> | null = null;
|
||||
private _activeProviderId$ = new BehaviorSubject<SyncProviderId | null>(null);
|
||||
|
|
@ -195,7 +145,21 @@ export class SyncProviderManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Gets the currently active sync provider
|
||||
* Ensures providers are loaded. Returns cached providers if already loaded.
|
||||
* Deduplication of concurrent calls is handled by `loadSyncProviders()`.
|
||||
*/
|
||||
private async _ensureProviders(): Promise<SyncProviderBase<SyncProviderId>[]> {
|
||||
if (this._providers) {
|
||||
return this._providers;
|
||||
}
|
||||
this._providers = await loadSyncProviders();
|
||||
return this._providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the currently active sync provider.
|
||||
* Note: May return null during initial lazy-load of provider modules.
|
||||
* Callers should gate on `isProviderReady$` before using the returned provider.
|
||||
*/
|
||||
getActiveProvider(): SyncProviderBase<SyncProviderId> | null {
|
||||
return this._activeProvider;
|
||||
|
|
@ -204,17 +168,19 @@ export class SyncProviderManager {
|
|||
/**
|
||||
* Gets a sync provider by ID
|
||||
*/
|
||||
getProviderById(
|
||||
async getProviderById(
|
||||
providerId: SyncProviderId,
|
||||
): SyncProviderBase<SyncProviderId> | undefined {
|
||||
return SYNC_PROVIDERS.find((p) => p.id === providerId);
|
||||
): Promise<SyncProviderBase<SyncProviderId> | undefined> {
|
||||
const providers = await this._ensureProviders();
|
||||
return providers.find((p) => p.id === providerId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets all available sync providers
|
||||
*/
|
||||
getAllProviders(): SyncProviderBase<SyncProviderId>[] {
|
||||
return [...SYNC_PROVIDERS];
|
||||
async getAllProviders(): Promise<SyncProviderBase<SyncProviderId>[]> {
|
||||
const providers = await this._ensureProviders();
|
||||
return [...providers];
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -245,7 +211,7 @@ export class SyncProviderManager {
|
|||
async getProviderConfig<PID extends SyncProviderId>(
|
||||
providerId: PID,
|
||||
): Promise<PrivateCfgByProviderId<PID> | null> {
|
||||
const provider = this.getProviderById(providerId);
|
||||
const provider = await this.getProviderById(providerId);
|
||||
if (!provider) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -259,7 +225,7 @@ export class SyncProviderManager {
|
|||
providerId: PID,
|
||||
config: PrivateCfgByProviderId<PID>,
|
||||
): Promise<void> {
|
||||
const provider = this.getProviderById(providerId);
|
||||
const provider = await this.getProviderById(providerId);
|
||||
if (!provider) {
|
||||
throw new Error(`Provider not found: ${providerId}`);
|
||||
}
|
||||
|
|
@ -288,7 +254,7 @@ export class SyncProviderManager {
|
|||
* Updates readiness state and config observable after clearing.
|
||||
*/
|
||||
async clearAuthCredentials(providerId: SyncProviderId): Promise<void> {
|
||||
const provider = this.getProviderById(providerId);
|
||||
const provider = await this.getProviderById(providerId);
|
||||
if (!provider?.clearAuthCredentials) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -314,7 +280,7 @@ export class SyncProviderManager {
|
|||
}
|
||||
|
||||
/**
|
||||
* Sets the active sync provider
|
||||
* Sets the active sync provider (loads providers lazily on first call)
|
||||
*/
|
||||
private _setActiveProvider(providerId: SyncProviderId | null): void {
|
||||
this._activeProviderId$.next(providerId);
|
||||
|
|
@ -326,22 +292,57 @@ export class SyncProviderManager {
|
|||
return;
|
||||
}
|
||||
|
||||
const provider = SYNC_PROVIDERS.find((p) => p.id === providerId);
|
||||
if (provider) {
|
||||
this._activeProvider = provider;
|
||||
provider.isReady().then((ready) => this._isProviderReady$.next(ready));
|
||||
// Load providers lazily and set the active one
|
||||
this._ensureProviders()
|
||||
.then((providers) => {
|
||||
// Guard against stale resolution if provider changed while loading
|
||||
if (this._activeProviderId$.getValue() !== providerId) {
|
||||
return;
|
||||
}
|
||||
const provider = providers.find((p) => p.id === providerId);
|
||||
if (provider) {
|
||||
this._activeProvider = provider;
|
||||
provider
|
||||
.isReady()
|
||||
.then((ready) => {
|
||||
if (this._activeProviderId$.getValue() !== providerId) return;
|
||||
this._isProviderReady$.next(ready);
|
||||
})
|
||||
.catch((err) => {
|
||||
SyncLog.err(
|
||||
'SyncProviderManager: Failed to check provider readiness:',
|
||||
err,
|
||||
);
|
||||
if (this._activeProviderId$.getValue() === providerId) {
|
||||
this._isProviderReady$.next(false);
|
||||
}
|
||||
});
|
||||
|
||||
// Emit provider config to observable
|
||||
provider.privateCfg.load().then((privateCfg) => {
|
||||
this._currentProviderPrivateCfg$.next({
|
||||
providerId,
|
||||
privateCfg,
|
||||
});
|
||||
// Emit provider config to observable
|
||||
provider.privateCfg
|
||||
.load()
|
||||
.then((privateCfg) => {
|
||||
if (this._activeProviderId$.getValue() !== providerId) return;
|
||||
this._currentProviderPrivateCfg$.next({
|
||||
providerId,
|
||||
privateCfg,
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
SyncLog.err('SyncProviderManager: Failed to load provider config:', err),
|
||||
);
|
||||
|
||||
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
|
||||
} else {
|
||||
SyncLog.err(`SyncProviderManager: Provider not found: ${providerId}`);
|
||||
}
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this._activeProviderId$.getValue() !== providerId) {
|
||||
return;
|
||||
}
|
||||
SyncLog.err('SyncProviderManager: Failed to load providers:', err);
|
||||
this._isProviderReady$.next(false);
|
||||
});
|
||||
|
||||
SyncLog.normal(`SyncProviderManager: Active provider set to ${providerId}`);
|
||||
} else {
|
||||
SyncLog.err(`SyncProviderManager: Provider not found: ${providerId}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
67
src/app/op-log/sync-providers/sync-providers.factory.ts
Normal file
67
src/app/op-log/sync-providers/sync-providers.factory.ts
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
import { SyncProviderId } from './provider.const';
|
||||
import { SyncProviderBase } from './provider.interface';
|
||||
import { DROPBOX_APP_KEY } from '../../imex/sync/dropbox/dropbox.const';
|
||||
import { IS_ELECTRON } from '../../app.constants';
|
||||
import { IS_ANDROID_WEB_VIEW } from '../../util/is-android-web-view';
|
||||
import { environment } from '../../../environments/environment';
|
||||
|
||||
let _providersPromise: Promise<SyncProviderBase<SyncProviderId>[]> | null = null;
|
||||
|
||||
/**
|
||||
* Lazily loads and instantiates all sync providers.
|
||||
* Provider modules (Dropbox, WebDAV, SuperSync, LocalFile) are only imported
|
||||
* when this function is first called, keeping them out of the initial bundle.
|
||||
*/
|
||||
export const loadSyncProviders = (): Promise<SyncProviderBase<SyncProviderId>[]> => {
|
||||
if (!_providersPromise) {
|
||||
_providersPromise = _createProviders().catch((err) => {
|
||||
_providersPromise = null;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return _providersPromise;
|
||||
};
|
||||
|
||||
/**
|
||||
* Narrow interface for LocalFile sync providers that expose directory picker methods.
|
||||
* Used to avoid `as any` casts in sync-form.const.ts.
|
||||
*/
|
||||
export interface LocalFileSyncPicker {
|
||||
pickDirectory(): Promise<string | void>;
|
||||
setupSaf(): Promise<string>;
|
||||
}
|
||||
|
||||
const _createProviders = async (): Promise<SyncProviderBase<SyncProviderId>[]> => {
|
||||
const [{ Dropbox }, { Webdav }, { SuperSyncProvider }] = await Promise.all([
|
||||
import('./file-based/dropbox/dropbox'),
|
||||
import('./file-based/webdav/webdav'),
|
||||
import('./super-sync/super-sync'),
|
||||
]);
|
||||
|
||||
const providers: SyncProviderBase<SyncProviderId>[] = [
|
||||
new Dropbox({
|
||||
appKey: DROPBOX_APP_KEY,
|
||||
basePath: environment.production ? `/` : `/DEV/`,
|
||||
}) as SyncProviderBase<SyncProviderId>,
|
||||
new Webdav(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
new SuperSyncProvider(
|
||||
environment.production ? undefined : `/DEV`,
|
||||
) as SyncProviderBase<SyncProviderId>,
|
||||
];
|
||||
|
||||
if (IS_ELECTRON) {
|
||||
const { LocalFileSyncElectron } =
|
||||
await import('./file-based/local-file/local-file-sync-electron');
|
||||
providers.push(new LocalFileSyncElectron() as SyncProviderBase<SyncProviderId>);
|
||||
}
|
||||
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
const { LocalFileSyncAndroid } =
|
||||
await import('./file-based/local-file/local-file-sync-android');
|
||||
providers.push(new LocalFileSyncAndroid() as SyncProviderBase<SyncProviderId>);
|
||||
}
|
||||
|
||||
return providers;
|
||||
};
|
||||
|
|
@ -20,7 +20,12 @@ let _validateFullPromise:
|
|||
| undefined;
|
||||
const _loadValidateFull = (): Promise<typeof import('./validation-fn').validateFull> => {
|
||||
if (!_validateFullPromise) {
|
||||
_validateFullPromise = import('./validation-fn').then((m) => m.validateFull);
|
||||
_validateFullPromise = import('./validation-fn')
|
||||
.then((m) => m.validateFull)
|
||||
.catch((err) => {
|
||||
_validateFullPromise = undefined;
|
||||
throw err;
|
||||
});
|
||||
}
|
||||
return _validateFullPromise;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -188,11 +188,11 @@
|
|||
</h2>
|
||||
<section class="config-section tour-syncSection">
|
||||
@let syncSettingsForm = syncSettingsService.syncSettingsForm$ | async;
|
||||
@if (syncSettingsForm) {
|
||||
@if (syncSettingsForm && globalSyncConfigFormCfg(); as syncCfg) {
|
||||
<config-section
|
||||
(save)="syncSettingsService.updateSettingsFromForm($event.config)"
|
||||
[cfg]="syncSettingsForm"
|
||||
[section]="globalSyncConfigFormCfg"
|
||||
[section]="syncCfg"
|
||||
></config-section>
|
||||
}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
signal,
|
||||
} from '@angular/core';
|
||||
import { ActivatedRoute } from '@angular/router';
|
||||
import { GlobalConfigService } from '../../features/config/global-config.service';
|
||||
|
|
@ -19,6 +20,7 @@ import {
|
|||
} from '../../features/config/global-config-form-config.const';
|
||||
import {
|
||||
ConfigFormConfig,
|
||||
ConfigFormSection,
|
||||
GlobalConfigSectionKey,
|
||||
GlobalConfigState,
|
||||
GlobalSectionConfig,
|
||||
|
|
@ -110,7 +112,7 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
pluginsShortcutsFormCfg: ConfigFormConfig;
|
||||
globalImexFormCfg: ConfigFormConfig;
|
||||
globalProductivityConfigFormCfg: ConfigFormConfig;
|
||||
globalSyncConfigFormCfg = this._buildSyncFormConfig();
|
||||
globalSyncConfigFormCfg = signal<ConfigFormSection<SyncConfig> | undefined>(undefined);
|
||||
|
||||
globalCfg?: GlobalConfigState;
|
||||
|
||||
|
|
@ -145,6 +147,17 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
this.globalProductivityConfigFormCfg = GLOBAL_PRODUCTIVITY_FORM_CONFIG.slice();
|
||||
this.globalTasksFormCfg = GLOBAL_TASKS_FORM_CONFIG.slice();
|
||||
|
||||
// Build sync form config asynchronously (providers are lazy-loaded)
|
||||
this._buildSyncFormConfig()
|
||||
.then((cfg) => this.globalSyncConfigFormCfg.set(cfg))
|
||||
.catch((err) => {
|
||||
Log.err('Failed to build sync form config:', err);
|
||||
this._snackService.open({
|
||||
type: 'ERROR',
|
||||
msg: T.GLOBAL_SNACK.OPEN_SETTINGS_ERROR,
|
||||
});
|
||||
});
|
||||
|
||||
// NOTE: needs special handling cause of the async stuff
|
||||
if (IS_ANDROID_WEB_VIEW) {
|
||||
this.globalImexFormCfg = [...this.globalImexFormCfg, getAutomaticBackUpFormCfg()];
|
||||
|
|
@ -254,7 +267,12 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
this._subs.unsubscribe();
|
||||
}
|
||||
|
||||
private _buildSyncFormConfig(): typeof SYNC_FORM {
|
||||
private async _buildSyncFormConfig(): Promise<typeof SYNC_FORM> {
|
||||
// Pre-load dropbox provider for use in the form config
|
||||
const dropboxProvider = await this._providerManager.getProviderById(
|
||||
SyncProviderId.Dropbox,
|
||||
);
|
||||
|
||||
// Deep clone the SYNC_FORM items to avoid mutating the original
|
||||
const items = SYNC_FORM.items!.map((item) => {
|
||||
// Find the WebDAV fieldGroup and add the Test Connection button
|
||||
|
|
@ -376,10 +394,6 @@ export class ConfigPageComponent implements OnInit, OnDestroy {
|
|||
// Find the Dropbox fieldGroup and add the authentication button
|
||||
if ((item as any).props?.dropboxAuth && item.fieldGroup) {
|
||||
// Check if Dropbox is already authenticated
|
||||
const dropboxProvider = this._providerManager.getProviderById(
|
||||
SyncProviderId.Dropbox,
|
||||
);
|
||||
|
||||
// We need to check auth status asynchronously, so we'll set initial state
|
||||
// and update it when the form is rendered
|
||||
let isAuthenticated = false;
|
||||
|
|
|
|||
|
|
@ -1,25 +1,73 @@
|
|||
import { Pipe, PipeTransform } from '@angular/core';
|
||||
|
||||
interface J2mModule {
|
||||
to_markdown(input: string): string;
|
||||
}
|
||||
|
||||
let _j2mPromise: Promise<J2mModule> | undefined;
|
||||
|
||||
const _loadJ2m = (): Promise<J2mModule> => {
|
||||
if (!_j2mPromise) {
|
||||
// @ts-ignore
|
||||
_j2mPromise = import('jira2md').then((m: { default: J2mModule }) => m.default);
|
||||
}
|
||||
return _j2mPromise;
|
||||
};
|
||||
/**
|
||||
* Converts JIRA wiki markup to Markdown.
|
||||
* Inlined from jira2md to avoid bundling its CommonJS `marked` dependency.
|
||||
*/
|
||||
const jiraToMarkdown = (str: string): string =>
|
||||
str
|
||||
// Un-ordered lists (JIRA: * for bullets)
|
||||
.replace(/^[ \t]*(\*+)\s+/gm, (_, stars: string) => {
|
||||
return ' '.repeat(stars.length - 1) + '* ';
|
||||
})
|
||||
// Ordered lists (JIRA: # for numbered)
|
||||
.replace(/^[ \t]*(#+)\s+/gm, (_, nums: string) => {
|
||||
return ' '.repeat(nums.length - 1) + '1. ';
|
||||
})
|
||||
// Headers 1-6
|
||||
.replace(/^h([1-6])\.(.*)$/gm, (_, level: string, content: string) => {
|
||||
return '#'.repeat(parseInt(level)) + content;
|
||||
})
|
||||
// Bold (intentionally lazy to handle multiple bold spans per line)
|
||||
.replace(/\*(\S.*?)\*/g, '**$1**')
|
||||
// Italic (intentionally lazy to handle multiple italic spans per line)
|
||||
.replace(/\_(\S.*?)\_/g, '*$1*')
|
||||
// Monospaced text
|
||||
.replace(/\{\{([^}]+)\}\}/g, '`$1`')
|
||||
// Inserts
|
||||
.replace(/\+([^+]*)\+/g, '<ins>$1</ins>')
|
||||
// Superscript
|
||||
.replace(/\^([^^]*)\^/g, '<sup>$1</sup>')
|
||||
// Subscript
|
||||
.replace(/~([^~]*)~/g, '<sub>$1</sub>')
|
||||
// Strikethrough
|
||||
.replace(/(\s+)-(\S+.*?\S)-(\s+)/g, '$1~~$2~~$3')
|
||||
// Code Block
|
||||
.replace(
|
||||
/\{code(:([a-z]+))?([:|]?(title|borderStyle|borderColor|borderWidth|bgColor|titleBGColor)=.+?)*\}([^]*?)\n?\{code\}/gm,
|
||||
'```$2$5\n```',
|
||||
)
|
||||
// Pre-formatted text
|
||||
.replace(/{noformat}/g, '```')
|
||||
// Un-named Links
|
||||
.replace(/\[([^|]+?)\]/g, '<$1>')
|
||||
// Images
|
||||
.replace(/!(.+)!/g, '')
|
||||
// Named Links
|
||||
.replace(/\[(.+?)\|(.+?)\]/g, '[$1]($2)')
|
||||
// Single Paragraph Blockquote
|
||||
.replace(/^bq\.\s+/gm, '> ')
|
||||
// Remove color: unsupported in md
|
||||
.replace(/\{color:[^}]+\}([^]*)\{color\}/gm, '$1')
|
||||
// panel into table
|
||||
.replace(
|
||||
/\{panel:title=([^}]*)\}\n?([^]*?)\n?\{panel\}/gm,
|
||||
'\n| $1 |\n| --- |\n| $2 |',
|
||||
)
|
||||
// table header
|
||||
.replace(/^[ \t]*((?:\|\|.*?)+\|\|)[ \t]*$/gm, (_, headers: string) => {
|
||||
const singleBarred = headers.replace(/\|\|/g, '|');
|
||||
return '\n' + singleBarred + '\n' + singleBarred.replace(/\|[^|]+/g, '| --- ');
|
||||
})
|
||||
// remove leading-space of table headers and rows
|
||||
.replace(/^[ \t]*\|/gm, '|');
|
||||
|
||||
@Pipe({ name: 'jiraToMarkdown' })
|
||||
export class JiraToMarkdownPipe implements PipeTransform {
|
||||
transform(value: string): Promise<string> {
|
||||
transform(value: string): string {
|
||||
if (!value) {
|
||||
return Promise.resolve(value);
|
||||
return value;
|
||||
}
|
||||
return _loadJ2m().then((j2m) => j2m.to_markdown(value));
|
||||
return jiraToMarkdown(value);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue