diff --git a/angular.json b/angular.json index 907d1a9f5d..6e6adb6b33 100644 --- a/angular.json +++ b/angular.json @@ -50,7 +50,6 @@ "spark-md5", "chrono-node", "dayjs", - "jira2md", "query-string" ] }, diff --git a/package.json b/package.json index 1836f95ecc..4dbf4c4a9d 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/app/features/config/form-cfgs/sync-form.const.ts b/src/app/features/config/form-cfgs/sync-form.const.ts index 69c1904ecc..2257fc2424 100644 --- a/src/app/features/config/form-cfgs/sync-form.const.ts +++ b/src/app/features/config/form-cfgs/sync-form.const.ts @@ -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 = { 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 = { 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: { diff --git a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts index 741c0bf182..e08f6d1ba0 100644 --- a/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts +++ b/src/app/imex/sync/dialog-sync-initial-cfg/dialog-sync-initial-cfg.component.ts @@ -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; diff --git a/src/app/imex/sync/dropbox/store/dropbox.effects.ts b/src/app/imex/sync/dropbox/store/dropbox.effects.ts index 5bb1c2c769..33cbd052c7 100644 --- a/src/app/imex/sync/dropbox/store/dropbox.effects.ts +++ b/src/app/imex/sync/dropbox/store/dropbox.effects.ts @@ -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; } diff --git a/src/app/imex/sync/sync-config.service.ts b/src/app/imex/sync/sync-config.service.ts index 5ed1b8724d..2d4d118d9a 100644 --- a/src/app/imex/sync/sync-config.service.ts +++ b/src/app/imex/sync/sync-config.service.ts @@ -228,7 +228,7 @@ export class SyncConfigService { syncProviderId?: SyncProviderId, ): Promise { 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 diff --git a/src/app/imex/sync/sync-wrapper.service.ts b/src/app/imex/sync/sync-wrapper.service.ts index ce108fe410..d2baf38c68 100644 --- a/src/app/imex/sync/sync-wrapper.service.ts +++ b/src/app/imex/sync/sync-wrapper.service.ts @@ -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 }; diff --git a/src/app/op-log/model/model-config.ts b/src/app/op-log/model/model-config.ts index 0a59029542..a98c698ddf 100644 --- a/src/app/op-log/model/model-config.ts +++ b/src/app/op-log/model/model-config.ts @@ -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 => { } 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] : []), -]; diff --git a/src/app/op-log/sync-exports.ts b/src/app/op-log/sync-exports.ts index 662856b059..ea3b548a42 100644 --- a/src/app/op-log/sync-exports.ts +++ b/src/app/op-log/sync-exports.ts @@ -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'; diff --git a/src/app/op-log/sync-providers/provider-manager.service.ts b/src/app/op-log/sync-providers/provider-manager.service.ts index f61b35697e..a82417a9e3 100644 --- a/src/app/op-log/sync-providers/provider-manager.service.ts +++ b/src/app/op-log/sync-providers/provider-manager.service.ts @@ -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[] = [ - new Dropbox({ - appKey: DROPBOX_APP_KEY, - basePath: environment.production ? `/` : `/DEV/`, - }) as SyncProviderBase, - new Webdav( - environment.production ? undefined : `/DEV`, - ) as SyncProviderBase, - new SuperSyncProvider( - environment.production ? undefined : `/DEV`, - ) as SyncProviderBase, - ...(IS_ELECTRON - ? [new LocalFileSyncElectron() as SyncProviderBase] - : []), - ...(IS_ANDROID_WEB_VIEW - ? [new LocalFileSyncAndroid() as SyncProviderBase] - : []), -]; - /** * 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[] | null = null; + // Current active provider private _activeProvider: SyncProviderBase | null = null; private _activeProviderId$ = new BehaviorSubject(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[]> { + 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 | null { return this._activeProvider; @@ -204,17 +168,19 @@ export class SyncProviderManager { /** * Gets a sync provider by ID */ - getProviderById( + async getProviderById( providerId: SyncProviderId, - ): SyncProviderBase | undefined { - return SYNC_PROVIDERS.find((p) => p.id === providerId); + ): Promise | undefined> { + const providers = await this._ensureProviders(); + return providers.find((p) => p.id === providerId); } /** * Gets all available sync providers */ - getAllProviders(): SyncProviderBase[] { - return [...SYNC_PROVIDERS]; + async getAllProviders(): Promise[]> { + const providers = await this._ensureProviders(); + return [...providers]; } /** @@ -245,7 +211,7 @@ export class SyncProviderManager { async getProviderConfig( providerId: PID, ): Promise | 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, ): Promise { - 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 { - 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}`); - } } } diff --git a/src/app/op-log/sync-providers/sync-providers.factory.ts b/src/app/op-log/sync-providers/sync-providers.factory.ts new file mode 100644 index 0000000000..1e3e93560f --- /dev/null +++ b/src/app/op-log/sync-providers/sync-providers.factory.ts @@ -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[]> | 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[]> => { + 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; + setupSaf(): Promise; +} + +const _createProviders = async (): Promise[]> => { + 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[] = [ + new Dropbox({ + appKey: DROPBOX_APP_KEY, + basePath: environment.production ? `/` : `/DEV/`, + }) as SyncProviderBase, + new Webdav( + environment.production ? undefined : `/DEV`, + ) as SyncProviderBase, + new SuperSyncProvider( + environment.production ? undefined : `/DEV`, + ) as SyncProviderBase, + ]; + + if (IS_ELECTRON) { + const { LocalFileSyncElectron } = + await import('./file-based/local-file/local-file-sync-electron'); + providers.push(new LocalFileSyncElectron() as SyncProviderBase); + } + + if (IS_ANDROID_WEB_VIEW) { + const { LocalFileSyncAndroid } = + await import('./file-based/local-file/local-file-sync-android'); + providers.push(new LocalFileSyncAndroid() as SyncProviderBase); + } + + return providers; +}; diff --git a/src/app/op-log/validation/validate-state.service.ts b/src/app/op-log/validation/validate-state.service.ts index f00064739e..96aa74e4dd 100644 --- a/src/app/op-log/validation/validate-state.service.ts +++ b/src/app/op-log/validation/validate-state.service.ts @@ -20,7 +20,12 @@ let _validateFullPromise: | undefined; const _loadValidateFull = (): Promise => { 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; }; diff --git a/src/app/pages/config-page/config-page.component.html b/src/app/pages/config-page/config-page.component.html index e6ca2b62f6..99249bb9cb 100644 --- a/src/app/pages/config-page/config-page.component.html +++ b/src/app/pages/config-page/config-page.component.html @@ -188,11 +188,11 @@
@let syncSettingsForm = syncSettingsService.syncSettingsForm$ | async; - @if (syncSettingsForm) { + @if (syncSettingsForm && globalSyncConfigFormCfg(); as syncCfg) { }
diff --git a/src/app/pages/config-page/config-page.component.ts b/src/app/pages/config-page/config-page.component.ts index 1b0443603b..f9ee092c13 100644 --- a/src/app/pages/config-page/config-page.component.ts +++ b/src/app/pages/config-page/config-page.component.ts @@ -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 | 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 { + // 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; diff --git a/src/app/ui/pipes/jira-to-markdown.pipe.ts b/src/app/ui/pipes/jira-to-markdown.pipe.ts index 14356595dc..b312288269 100644 --- a/src/app/ui/pipes/jira-to-markdown.pipe.ts +++ b/src/app/ui/pipes/jira-to-markdown.pipe.ts @@ -1,25 +1,73 @@ import { Pipe, PipeTransform } from '@angular/core'; -interface J2mModule { - to_markdown(input: string): string; -} - -let _j2mPromise: Promise | undefined; - -const _loadJ2m = (): Promise => { - 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, '$1') + // Superscript + .replace(/\^([^^]*)\^/g, '$1') + // Subscript + .replace(/~([^~]*)~/g, '$1') + // 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, '![]($1)') + // 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 { + transform(value: string): string { if (!value) { - return Promise.resolve(value); + return value; } - return _loadJ2m().then((j2m) => j2m.to_markdown(value)); + return jiraToMarkdown(value); } }