1- import { Editor , Notice , Plugin } from "obsidian" ;
1+ import { Editor , Modal , Notice , Plugin , TFile , setIcon } from "obsidian" ;
22import {
33 DEFAULT_SETTINGS ,
44 AnkiLinkSettings ,
55 AnkiLinkSettingsTab ,
66} from "./settings" ;
77import { syncVaultNotes } from "./syncUtil" ;
8+ import { FC_PREAMBLE_P } from "./regexUtil" ;
9+
10+ const ANKI_LINK_ICON = "circle-question-mark" ;
811
912export default class AnkiLink extends Plugin {
1013 settings ! : AnkiLinkSettings ;
14+ private statusBarItemEl ! : HTMLElement ;
15+ private statusBarRefreshToken = 0 ;
1116
1217 async onload ( ) {
1318 await this . loadSettings ( ) ;
1419
1520 this . addRibbonIcon (
16- "circle-question-mark" ,
21+ ANKI_LINK_ICON ,
1722 "Sample" ,
1823 async ( _evt : MouseEvent ) => {
1924 await this . runSyncAndNotify ( ) ;
@@ -36,6 +41,28 @@ export default class AnkiLink extends Plugin {
3641 } ,
3742 } ) ;
3843
44+ this . statusBarItemEl = this . addStatusBarItem ( ) ;
45+ this . statusBarItemEl . addClass ( "anki-link-status" ) ;
46+ this . statusBarItemEl . addClass ( "mod-clickable" ) ;
47+ this . registerDomEvent ( this . statusBarItemEl , "click" , ( ) => {
48+ void this . showMissingDeckNotesModal ( ) ;
49+ } ) ;
50+ void this . refreshStatusBar ( ) ;
51+
52+ this . registerEvent ( this . app . workspace . on ( "file-open" , ( ) => {
53+ void this . refreshStatusBar ( ) ;
54+ } ) ) ;
55+ this . registerEvent ( this . app . vault . on ( "modify" , ( file ) => {
56+ const activeFile = this . app . workspace . getActiveFile ( ) ;
57+ if ( activeFile ?. path !== file . path ) return ;
58+ void this . refreshStatusBar ( ) ;
59+ } ) ) ;
60+ this . registerEvent ( this . app . metadataCache . on ( "changed" , ( file ) => {
61+ const activeFile = this . app . workspace . getActiveFile ( ) ;
62+ if ( activeFile ?. path !== file . path ) return ;
63+ void this . refreshStatusBar ( ) ;
64+ } ) ) ;
65+
3966 this . addSettingTab ( new AnkiLinkSettingsTab ( this . app , this ) ) ;
4067 }
4168
@@ -100,4 +127,121 @@ export default class AnkiLink extends Plugin {
100127
101128 return "Unknown error" ;
102129 }
130+
131+ private async refreshStatusBar ( ) : Promise < void > {
132+ const refreshToken = ++ this . statusBarRefreshToken ;
133+ const activeFile = this . app . workspace . getActiveFile ( ) ;
134+ if ( activeFile ?. extension !== "md" ) {
135+ this . setStatusBarState ( "Anki: -" ) ;
136+ return ;
137+ }
138+
139+ const hasFlashcards = await this . fileHasFlashcards ( activeFile ) ;
140+ if ( refreshToken !== this . statusBarRefreshToken ) return ;
141+
142+ const configuredDeck = this . getConfiguredDeck ( activeFile ) ;
143+ if ( ! hasFlashcards ) {
144+ this . setStatusBarState ( "Anki: no cards" ) ;
145+ return ;
146+ }
147+ if ( ! configuredDeck ) {
148+ this . setStatusBarState ( "Anki: ⚠ deck missing" , true ) ;
149+ return ;
150+ }
151+ this . setStatusBarState ( `Anki: ${ configuredDeck } ` ) ;
152+ }
153+
154+ private async fileHasFlashcards ( file : TFile ) : Promise < boolean > {
155+ const content = await this . app . vault . read ( file ) ;
156+ return content
157+ . split ( "\n" )
158+ . some ( ( line ) => FC_PREAMBLE_P . test ( line ) ) ;
159+ }
160+
161+ private async showMissingDeckNotesModal ( ) : Promise < void > {
162+ const loadingNotice = new Notice ( "Checking notes for missing Anki deck..." , 0 ) ;
163+ try {
164+ const missingDeckFiles = await this . findNotesMissingDeckNames ( ) ;
165+ new MissingDeckNotesModal ( this , missingDeckFiles ) . open ( ) ;
166+ } finally {
167+ loadingNotice . hide ( ) ;
168+ }
169+ }
170+
171+ private async findNotesMissingDeckNames ( ) : Promise < TFile [ ] > {
172+ const markdownFiles = this . app . vault . getMarkdownFiles ( ) ;
173+ const missingDeckFiles : TFile [ ] = [ ] ;
174+ for ( const file of markdownFiles ) {
175+ if ( this . getConfiguredDeck ( file ) ) continue ;
176+ if ( await this . fileHasFlashcards ( file ) ) {
177+ missingDeckFiles . push ( file ) ;
178+ }
179+ }
180+ return missingDeckFiles ;
181+ }
182+
183+ private getConfiguredDeck ( file : TFile ) : string | undefined {
184+ const frontmatter = this . app . metadataCache . getFileCache ( file ) ?. frontmatter ;
185+ if ( ! frontmatter ) return undefined ;
186+ for ( const [ key , value ] of Object . entries ( frontmatter ) ) {
187+ if ( key . toLowerCase ( ) !== "anki deck" ) continue ;
188+ if ( typeof value !== "string" ) continue ;
189+ const trimmed = value . trim ( ) ;
190+ if ( trimmed . length > 0 ) {
191+ return trimmed ;
192+ }
193+ }
194+ return undefined ;
195+ }
196+
197+ private setStatusBarState ( text : string , isWarning = false ) : void {
198+ this . statusBarItemEl . empty ( ) ;
199+ this . statusBarItemEl . setAttribute ( "aria-label" , text ) ;
200+ this . statusBarItemEl . setAttribute ( "title" , text ) ;
201+ setIcon ( this . statusBarItemEl , ANKI_LINK_ICON ) ;
202+ this . statusBarItemEl . style . color = isWarning ? "var(--text-error)" : "" ;
203+ }
204+ }
205+
206+ class MissingDeckNotesModal extends Modal {
207+ constructor (
208+ private readonly plugin : AnkiLink ,
209+ private readonly files : TFile [ ] ,
210+ ) {
211+ super ( plugin . app ) ;
212+ }
213+
214+ override onOpen ( ) : void {
215+ const { contentEl } = this ;
216+ contentEl . empty ( ) ;
217+ contentEl . createEl ( "h3" , { text : "Notes missing Anki deck" } ) ;
218+
219+ if ( this . files . length === 0 ) {
220+ contentEl . createEl ( "p" , { text : "No notes with flashcards are missing an Anki deck." } ) ;
221+ return ;
222+ }
223+
224+ contentEl . createEl ( "p" , {
225+ text : `${ this . files . length } note${ this . files . length === 1 ? "" : "s" } found.` ,
226+ } ) ;
227+ const listEl = contentEl . createEl ( "ul" , { cls : "anki-link-missing-deck-list" } ) ;
228+ for ( const file of this . files ) {
229+ const itemEl = listEl . createEl ( "li" ) ;
230+ const linkEl = itemEl . createEl ( "a" , { text : file . path } ) ;
231+ linkEl . href = "#" ;
232+ linkEl . addEventListener ( "click" , ( event ) => {
233+ event . preventDefault ( ) ;
234+ void this . openFile ( file ) ;
235+ } ) ;
236+ }
237+ }
238+
239+ override onClose ( ) : void {
240+ this . contentEl . empty ( ) ;
241+ }
242+
243+ private async openFile ( file : TFile ) : Promise < void > {
244+ await this . plugin . app . workspace . getLeaf ( true ) . openFile ( file ) ;
245+ this . close ( ) ;
246+ }
103247}
0 commit comments