import { AttachmentID , AttachmentIngestEvent , AttachmentMIMEType , Event , EventType , generateUniqueID , getAttachmentMIMETypeForFilename , mainTaskComponentID , TaskContentType , TaskID , TaskIngestEvent , TaskRepetitionEvent , TaskRepetitionOutcome , TaskRescheduleEvent , TaskSpec , } from "@withorbit/core" ; import fs from "fs" ; import * as Anki from "./ankiPkg" ; import { Card , CardQueue , Collection } from "./ankiPkg" ; import { convertAnkiID } from "./convertAnkiID" ; import { getModelMapping , ModelMapping } from "./modelMapping" ; import { mapNoteToTaskSpec } from "./noteMapping" ; interface ImportPlan { events : Event [ ] ; attachments : Attachment [ ] ; issues : string [ ] ; } interface Attachment { contents : Uint8Array ; mimeType : AttachmentMIMEType ; id : AttachmentID ; } export function createSpecForNote ( note , ankiCollection , modelMappingCache ) { const modelID = note . mid ; let modelMapping = modelMappingCache [ modelID ] ; if ( ! modelMapping ) { const tentativeModelMapping = getModelMapping ( ankiCollection , modelID ) ; if ( tentativeModelMapping === "unknown" ) { return new Error ( `Note ${ note . id } references model of unknown type with ID ${ modelID }` ) ; } else if ( tentativeModelMapping === "missing" ) { return new Error ( `Note ${ note . id } references missing model with ID ${ modelID }` ) ; } modelMapping = tentativeModelMapping ; modelMappingCache [ modelID ] = tentativeModelMapping ; } return mapNoteToTaskSpec ( note , modelMapping ) ; } export async function createPlanForLog ( ankiLog , taskID , componentID ) { let outcome ; switch ( ankiLog . type ) { case Anki . ReviewLogType . Learn : case Anki . ReviewLogType . Relearn : outcome = ankiLog . ease === Anki . LearnEaseRating . Again ? TaskRepetitionOutcome . Forgotten : TaskRepetitionOutcome . Remembered ; break ; case Anki . ReviewLogType . Cram : case Anki . ReviewLogType . Review : outcome = ankiLog . ease === Anki . ReviewEaseRating . Again ? TaskRepetitionOutcome . Forgotten : TaskRepetitionOutcome . Remembered ; break ; } return { type : EventType . TaskRepetition , id : generateUniqueID ( ) , entityID : taskID , outcome , timestampMillis : ankiLog . id , reviewSessionID : "anki" , componentID , } ; } async function readAttachmentAtPath ( name , path ) { const mimeType = getAttachmentMIMETypeForFilename ( name ) ; if ( mimeType ) { const contents = await fs . promises . readFile ( path ) ; return { mimeType , contents , id : convertAnkiID ( name ) } ; } else { return new Error ( `Unsupported attachment type: ${ name }` ) ; } } export function createRescheduleEventForCard ( card , collection , taskID , componentID ) { let newDueTimestampMillis ; switch ( card . queue ) { case CardQueue . UserBuried : case CardQueue . Buried : case CardQueue . Suspended : case CardQueue . New : return null ; case CardQueue . Learning : newDueTimestampMillis = card . due * 1000 ; break ; case CardQueue . Due : case CardQueue . LearningNotDue : newDueTimestampMillis = collection . crt * 1000 + card . due * 1000 * 60 * 60 * 24 ; break ; } return { type : EventType . TaskReschedule , timestampMillis : Date . now ( ) , id : generateUniqueID ( ) , newDueTimestampMillis , entityID : taskID , componentID , } ; } export function getComponentID ( card , contentType ) { switch ( contentType ) { case TaskContentType . Plain : case TaskContentType . QA : if ( card . ord === 0 ) { return mainTaskComponentID ; } else { return null ; } case TaskContentType . Cloze : return card . ord . toString ( ) ; } } export async function createImportPlan ( handle , mediaManifest , attachmentIDsToExtractedPaths ) { const plan = { events : [ ] , attachments : [ ] , issues : [ ] , } ; const collection = await Anki . readCollection ( handle ) ; const modelMappingCache = { } ; const noteIDsToTaskSpecs = new Map < number , TaskSpec > ( ) ; if ( mediaManifest ) { await Promise . all ( Object . entries ( mediaManifest ) . map ( async ( [ ankiAttachmentID , attachmentName ] ) => { const attachmentPath = attachmentIDsToExtractedPaths [ ankiAttachmentID ] ; const result = await readAttachmentAtPath ( attachmentName , attachmentPath ) ; if ( result instanceof Error ) { plan . issues . push ( result . message ) ; } else { const attachmentIngestEvent = { type : EventType . AttachmentIngest , id : generateUniqueID ( ) , entityID : result . id , mimeType : result . mimeType , timestampMillis : Date . now ( ) , } ; plan . events . push ( attachmentIngestEvent ) ; plan . attachments . push ( result ) ; } } ) ) ; } await Anki . readNotes ( handle , async ( note ) => { const result = createSpecForNote ( note , collection , modelMappingCache ) ; if ( result instanceof Error ) { plan . issues . push ( result . message ) ; } else { const { spec , issues } = result ; plan . issues . push ( ... issues ) ; noteIDsToTaskSpecs . set ( note . id , spec ) ; } } ) ; const cardIDsToCards = new Map ( ) ; const noteIDsToCards = new Map ( ) ; await Anki . readCards ( handle , async ( card ) => { cardIDsToCards . set ( card . id , card ) ; const priorNoteCards = noteIDsToCards . get ( card . nid ) ; noteIDsToCards . set ( card . nid , priorNoteCards ? [ ... priorNoteCards , card ] : [ card ] ) ; } ) ; const noteIDsToTaskIDs = new Map < number , TaskID > ( ) ; function addIngestEventForNoteID ( noteID , taskSpec , timestampMillis ) { const cards = noteIDsToCards . get ( noteID ) ; if ( ! cards ) throw new Error ( `Inconsistent: note ${ noteID } has no cards` ) ; const metadata = { } ; for ( const card of cards ) { const componentID = getComponentID ( card , taskSpec . content . type ) ; if ( componentID ) { metadata [ `ankiCardID_${ componentID }` ] = card . id . toString ( ) ; } } const ingestEvent = { type : EventType . TaskIngest , id : generateUniqueID ( ) , entityID : generateUniqueID ( ) , spec : taskSpec , provenance : { identifier : `ankiNoteID_${ noteID }` , } , timestampMillis , metadata , } ; plan . events . push ( ingestEvent ) ; return ingestEvent ; } await Anki . readLogs ( handle , async ( ankiLog ) => { const card = cardIDsToCards . get ( ankiLog . cid ) ; if ( ! card ) { throw new Error ( `Inconsistent: log ${ ankiLog . id } references missing card with ID ${ ankiLog . cid }` ) ; } const spec = noteIDsToTaskSpecs . get ( card . nid ) ; if ( ! spec ) return ; const componentID = getComponentID ( card , spec . content . type ) ; if ( componentID === null ) return ; let taskID = noteIDsToTaskIDs . get ( card . nid ) ; if ( taskID === undefined ) { const event = addIngestEventForNoteID ( card . nid , spec , ankiLog . id ) ; taskID = event . entityID ; noteIDsToTaskIDs . set ( card . nid , taskID ) ; } const repetitionEvent = await createPlanForLog ( ankiLog , taskID , componentID ) ; plan . events . push ( repetitionEvent ) ; } ) ; const stragglerNoteIDs = new Set < number > ( ) ; await Anki . readCards ( handle , async ( card ) => { const spec = noteIDsToTaskSpecs . get ( card . nid ) ; if ( ! spec ) return ; if ( stragglerNoteIDs . has ( card . nid ) ) return ; const taskID = noteIDsToTaskIDs . get ( card . nid ) ; if ( taskID === undefined ) { addIngestEventForNoteID ( card . nid , spec , card . id ) ; stragglerNoteIDs . add ( card . nid ) ; } else { const componentID = getComponentID ( card , spec . content . type ) ; if ( componentID ) { const rescheduleEvent = await createRescheduleEventForCard ( card , collection , taskID , componentID ) ; if ( rescheduleEvent ) { plan . events . push ( rescheduleEvent ) ; } } } } ) ; return plan ; }