1- // eslint-disable-next-line max-classes-per-file
2- import papi , { logger , WebViewFactory } from '@papi/backend' ;
1+ import papi , { logger , WebViewFactory } from '@papi/backend' ;
32import type {
43 ExecutionActivationContext ,
5- ExecutionToken ,
64 IWebViewProvider ,
75 OpenWebViewOptions ,
86 SavedWebViewDefinition ,
@@ -12,14 +10,9 @@ import { SerializedVerseRef } from '@sillsdev/scripture';
1210import {
1311 getErrorMessage ,
1412 serialize ,
15- Unsubscriber ,
16- UnsubscriberAsync ,
1713 USFM_MARKERS_MAP_PARATEXT_3_0 ,
1814 UsjNodeAndDocumentLocation ,
1915 UsjReaderWriter ,
20- Mutex ,
21- MutexMap ,
22- formatReplacementString ,
2316} from 'platform-bible-utils' ;
2417import {
2518 EditorDecorations ,
@@ -32,21 +25,14 @@ import { AnnotationStyleDataProviderEngine } from './annotation-style.data-provi
3225import { mergeDecorations } from './decorations.util' ;
3326import platformScriptureEditorWebViewStyles from './platform-scripture-editor.web-view.scss?inline' ;
3427import platformScriptureEditorWebView from './platform-scripture-editor.web-view?inline' ;
35- import { formatEditorTitle } from './platform-scripture-editor.utils' ;
28+ import {
29+ formatEditorTitle ,
30+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
31+ } from './platform-scripture-editor.utils' ;
32+ import { MarkersViewNotifier } from './markers-view-notifier.model' ;
3633
3734logger . debug ( 'Scripture Editor is importing!' ) ;
3835
39- const scriptureEditorWebViewType = 'platformScriptureEditor.react' ;
40-
41- /**
42- * Time in milliseconds to wait before sending another notification informing the user that the
43- * editor is read-only because they are in the markers view
44- */
45- const READONLY_NOTIFICATION_DISMISS_DURATION_MS = 24 * 60 * 60 * 1000 ;
46-
47- /** Time in milliseconds to throttle repeat notifications for the same project */
48- const READONLY_NOTIFICATION_THROTTLE_MS = 5 * 1000 ;
49-
5036interface PlatformScriptureEditorOptions extends OpenWebViewOptions {
5137 projectId : string | undefined ;
5238 isReadOnly : boolean ;
@@ -194,7 +180,7 @@ async function insertFootnoteAtSelection(webViewId: string | undefined): Promise
194180 }
195181
196182 const webViewController = await papi . webViews . getWebViewController (
197- scriptureEditorWebViewType ,
183+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
198184 webViewId ,
199185 ) ;
200186
@@ -213,7 +199,7 @@ async function insertCrossReferenceAtSelection(webViewId: string | undefined): P
213199 }
214200
215201 const webViewController = await papi . webViews . getWebViewController (
216- scriptureEditorWebViewType ,
202+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
217203 webViewId ,
218204 ) ;
219205
@@ -284,7 +270,7 @@ async function open(
284270 // REVIEW: If an editor is already open for the selected project, we open another.
285271 // This matches the current behavior in P9, though it might not be what we want long-term.
286272 return papi . webViews . openWebView (
287- scriptureEditorWebViewType ,
273+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
288274 existingTabIdToReplace
289275 ? { type : 'replace-tab' , targetTabId : existingTabIdToReplace }
290276 : undefined ,
@@ -306,13 +292,13 @@ async function changeScriptureView(webViewId: string | undefined): Promise<void>
306292 return ;
307293 }
308294
309- if ( webViewDefinition . webViewType !== scriptureEditorWebViewType ) {
295+ if ( webViewDefinition . webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE ) {
310296 logger . debug ( `WebView is not a Scripture editor!` ) ;
311297 return ;
312298 }
313299
314300 const controller = await papi . webViews . getWebViewController (
315- scriptureEditorWebViewType ,
301+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
316302 webViewId ,
317303 ) ;
318304
@@ -336,13 +322,13 @@ async function toggleFootnotesPane(webViewId: string | undefined): Promise<void>
336322 return ;
337323 }
338324
339- if ( webViewDefinition . webViewType !== scriptureEditorWebViewType ) {
325+ if ( webViewDefinition . webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE ) {
340326 logger . debug ( `WebView is not a Scripture editor!` ) ;
341327 return ;
342328 }
343329
344330 const controller = await papi . webViews . getWebViewController (
345- scriptureEditorWebViewType ,
331+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
346332 webViewId ,
347333 ) ;
348334
@@ -366,13 +352,13 @@ async function changeFootnotesPaneLocation(webViewId: string | undefined): Promi
366352 return ;
367353 }
368354
369- if ( webViewDefinition . webViewType !== scriptureEditorWebViewType ) {
355+ if ( webViewDefinition . webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE ) {
370356 logger . debug ( `WebView is not a Scripture editor!` ) ;
371357 return ;
372358 }
373359
374360 const controller = await papi . webViews . getWebViewController (
375- scriptureEditorWebViewType ,
361+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
376362 webViewId ,
377363 ) ;
378364
@@ -385,18 +371,18 @@ async function changeFootnotesPaneLocation(webViewId: string | undefined): Promi
385371}
386372
387373/** Simple WebView provider so PAPI can get a Scripture Editor upon request */
388- class ScriptureEditorWebViewFactory extends WebViewFactory < typeof scriptureEditorWebViewType > {
374+ class ScriptureEditorWebViewFactory extends WebViewFactory < typeof SCRIPTURE_EDITOR_WEBVIEW_TYPE > {
389375 constructor ( ) {
390- super ( scriptureEditorWebViewType ) ;
376+ super ( SCRIPTURE_EDITOR_WEBVIEW_TYPE ) ;
391377 }
392378
393379 override async getWebViewDefinition (
394380 savedWebView : SavedWebViewDefinition ,
395381 getWebViewOptions : PlatformScriptureEditorOptions ,
396382 ) : Promise < WebViewDefinition | undefined > {
397- if ( savedWebView . webViewType !== scriptureEditorWebViewType )
383+ if ( savedWebView . webViewType !== SCRIPTURE_EDITOR_WEBVIEW_TYPE )
398384 throw new Error (
399- `${ scriptureEditorWebViewType } provider received request to provide a ${ savedWebView . webViewType } WebView` ,
385+ `${ SCRIPTURE_EDITOR_WEBVIEW_TYPE } provider received request to provide a ${ savedWebView . webViewType } WebView` ,
400386 ) ;
401387
402388 // We know that the projectId (if present in the state) will be a string.
@@ -724,183 +710,6 @@ class ScriptureEditorWebViewFactory extends WebViewFactory<typeof scriptureEdito
724710}
725711const scriptureEditorWebViewProvider : IWebViewProvider = new ScriptureEditorWebViewFactory ( ) ;
726712
727- /**
728- * Notifier that alerts users when a Scripture Editor WebView is opened or updated with
729- * `state.viewType === 'markers'`. Allows dismissing notifications per-project for 24 hours.
730- */
731- class MarkerViewNotifier {
732- /** UserData storage key for the disabled map */
733- private readonly disabledMapUserDataKey =
734- 'platformScriptureEditor.markerViewNotificationDisabled' ;
735-
736- /** Map of notification IDs to project IDs */
737- private readonly projectIdsByNotificationId = new Map < string | number , string > ( ) ;
738-
739- /** Map of project IDs to the time (in milliseconds) they were disabled */
740- private disabledMap : Record < string , number > = { } ;
741- /** Map of project IDs to timestamp (ms) of the last sent notification */
742- private lastNotificationByProject : Record < string , number > = { } ;
743- /** Map of per-project mutexes to avoid races when checking/updating last notification timestamps */
744- private projectMutexes = new MutexMap ( ) ;
745-
746- constructor (
747- private papiInstance : typeof papi ,
748- private executionToken : ExecutionToken ,
749- ) { }
750-
751- /**
752- * Starts the notifier by loading persisted state and registering necessary commands and listeners
753- *
754- * @returns Array of disposables for the registered commands and listeners
755- */
756- async start ( ) : Promise < ( Unsubscriber | UnsubscriberAsync ) [ ] > {
757- // Load persisted disable map once and cache it in memory
758- try {
759- const rawDisableMap =
760- ( await this . papiInstance . storage . readUserData (
761- this . executionToken ,
762- this . disabledMapUserDataKey ,
763- ) ) ?? '{}' ;
764- this . disabledMap = JSON . parse ( rawDisableMap ) ;
765- } catch ( e ) {
766- logger . warn ( `Failed to load marker-view disable map from storage: ${ getErrorMessage ( e ) } ` ) ;
767- this . disabledMap = { } ;
768- }
769- // Register command to dismiss for a project for 24 hours
770- const dismissCommand = this . papiInstance . commands . registerCommand (
771- 'platformScriptureEditor.dismissMarkerNotificationForProjectToday' ,
772- async ( notificationId ) => {
773- if ( ! notificationId ) return ;
774- const projectId = this . projectIdsByNotificationId . get ( notificationId ) ;
775- if ( ! projectId ) return ;
776- // Update in-memory map and persist
777- this . disabledMap [ projectId ] = Date . now ( ) ;
778- try {
779- await this . papiInstance . storage . writeUserData (
780- this . executionToken ,
781- this . disabledMapUserDataKey ,
782- JSON . stringify ( this . disabledMap ) ,
783- ) ;
784- } catch ( e ) {
785- logger . warn (
786- `Failed to persist marker-view disable map for project ${ projectId } : ${ getErrorMessage ( e ) } ` ,
787- ) ;
788- }
789- // Clean up the runtime mapping
790- this . projectIdsByNotificationId . delete ( notificationId ) ;
791- } ,
792- {
793- method : {
794- summary : "Don't show marker view readonly notification for this project for 24 hours" ,
795- params : [ { name : 'notificationId' , required : true , schema : { type : 'string' } } ] ,
796- result : { name : 'return value' , schema : { type : 'null' } } ,
797- } ,
798- } ,
799- ) ;
800-
801- // Listeners for new or updated webviews
802- const onOpen = this . papiInstance . webViews . onDidOpenWebView ( ( { webView } ) =>
803- this . handleWebView ( webView ) ,
804- ) ;
805- const onUpdate = this . papiInstance . webViews . onDidUpdateWebView ( ( { webView } ) =>
806- this . handleWebView ( webView ) ,
807- ) ;
808-
809- return [ await dismissCommand , onOpen , onUpdate ] ;
810- }
811-
812- /**
813- * Handles a WebView being opened or updated, sending a notification if it is a markers view and
814- * notifications are not disabled for its project
815- *
816- * @param webViewDefinition WebView that was opened or updated
817- */
818- private async handleWebView ( webViewDefinition : SavedWebViewDefinition | WebViewDefinition ) {
819- try {
820- // We are only interested in our editor WebViews, so ignore others
821- if ( webViewDefinition . webViewType !== scriptureEditorWebViewType ) return ;
822- // TypeScript doesn't know the shape of state, but we know viewType (if present) is string
823- // eslint-disable-next-line no-type-assertion/no-type-assertion
824- const viewType = webViewDefinition . state ?. viewType as string | undefined ;
825-
826- // Only need to send a notification if this is the markers view which is currently readonly
827- if ( viewType !== 'markers' ) return ;
828-
829- const { projectId, state } = webViewDefinition ;
830- // If no projectId, can't proceed. Just warn and be done
831- if ( ! projectId ) {
832- logger . warn (
833- `MarkerViewNotifier found an editor view WebView (${ webViewDefinition . id } ) with no projectId!` ,
834- ) ;
835- return ;
836- }
837-
838- // If the view is already read-only, no need to notify
839- if ( state ?. isReadOnly ) return ;
840-
841- // Check if this notification is disabled for this project
842- const disabledTime = this . disabledMap [ projectId ] ;
843- // If the disable time hasn't elapsed yet, don't send another notification
844- if ( disabledTime && Date . now ( ) < disabledTime + READONLY_NOTIFICATION_DISMISS_DURATION_MS )
845- return ;
846-
847- // Acquire a per-project mutex and atomically check/reserve the last-notification timestamp
848- // so we don't send the same notification multiple times in quick succession
849- const projectMutex : Mutex = this . projectMutexes . get ( projectId ) ;
850- await projectMutex . runExclusive ( async ( ) => {
851- const lastSent = this . lastNotificationByProject [ projectId ] ;
852- if ( lastSent && Date . now ( ) < lastSent + READONLY_NOTIFICATION_THROTTLE_MS ) return ;
853-
854- // Resolve project name if possible
855- let projectName = projectId ;
856- try {
857- const pdp = await this . papiInstance . projectDataProviders . get ( 'platform.base' , projectId ) ;
858- projectName = ( await pdp . getSetting ( 'platform.name' ) ) ?? projectId ;
859- } catch ( e ) {
860- // fall back to id
861- logger . warn (
862- `MarkerViewNotifier failed to get project name for project ${ projectId } : ${ getErrorMessage (
863- e ,
864- ) } `,
865- ) ;
866- }
867-
868- // Localize and format the notification message and click label
869- const messageKey = '%platformScriptureEditor_markersView_readonly_message_format%' ;
870- const clickLabelKey = '%platformScriptureEditor_markersView_readonly_dismiss%' ;
871- let localizedMessageTemplate = messageKey ;
872- let localizedClickLabel = clickLabelKey ;
873- try {
874- const localized = await this . papiInstance . localization . getLocalizedStrings ( {
875- localizeKeys : [ messageKey , clickLabelKey ] ,
876- } ) ;
877- localizedMessageTemplate = localized [ messageKey ] ?? messageKey ;
878- localizedClickLabel = localized [ clickLabelKey ] ?? localizedClickLabel ;
879- } catch ( e ) {
880- logger . warn (
881- `Failed to get localized strings for marker-view notification: ${ getErrorMessage ( e ) } ` ,
882- ) ;
883- }
884-
885- const message = formatReplacementString ( localizedMessageTemplate , { projectName } ) ;
886-
887- // Send notification informing that markers view is read-only for this WebView/project
888- const notificationId = await this . papiInstance . notifications . send ( {
889- severity : 'info' ,
890- message,
891- clickCommand : 'platformScriptureEditor.dismissMarkerNotificationForProjectToday' ,
892- clickCommandLabel : localizedClickLabel ,
893- } ) ;
894- this . projectIdsByNotificationId . set ( notificationId , projectId ) ;
895-
896- // Record the last-send time so we don't send duplicates too quickly
897- this . lastNotificationByProject [ projectId ] = Date . now ( ) ;
898- } ) ;
899- } catch ( e ) {
900- logger . warn ( `MarkerViewNotifier failed handling WebView: ${ getErrorMessage ( e ) } ` ) ;
901- }
902- }
903- }
904713export async function activate ( context : ExecutionActivationContext ) : Promise < void > {
905714 logger . debug ( 'Scripture editor is activating!' ) ;
906715
@@ -1012,7 +821,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
1012821 ) ;
1013822
1014823 const scriptureEditorWebViewProviderPromise = papi . webViewProviders . registerWebViewProvider (
1015- scriptureEditorWebViewType ,
824+ SCRIPTURE_EDITOR_WEBVIEW_TYPE ,
1016825 scriptureEditorWebViewProvider ,
1017826 ) ;
1018827
@@ -1084,7 +893,7 @@ export async function activate(context: ExecutionActivationContext): Promise<voi
1084893 ) ;
1085894
1086895 // Await the registration promises at the end so we don't hold everything else up
1087- const markerNotifier = new MarkerViewNotifier ( papi , context . executionToken ) ;
896+ const markerNotifier = new MarkersViewNotifier ( papi , context . executionToken ) ;
1088897 const markerNotifierUnsubscribers = await markerNotifier . start ( ) ;
1089898
1090899 context . registrations . add (
0 commit comments