Add JSON input (#3)
Co-authored-by: Smaug123 <patrick+github@patrickstevens.co.uk> Reviewed-on: #3
This commit is contained in:
35
AnkiStatic.Lib/AnkiStatic.Lib.fsproj
Normal file
35
AnkiStatic.Lib/AnkiStatic.Lib.fsproj
Normal file
@@ -0,0 +1,35 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="AssemblyInfo.fs" />
|
||||
<Compile Include="Domain\Deck.fs"/>
|
||||
<Compile Include="Domain\Model.fs"/>
|
||||
<Compile Include="Domain\Note.fs"/>
|
||||
<Compile Include="Domain\Card.fs"/>
|
||||
<Compile Include="Domain\Review.fs"/>
|
||||
<Compile Include="Domain\DeckConfiguration.fs"/>
|
||||
<Compile Include="Domain\CollectionConfiguration.fs"/>
|
||||
<Compile Include="Domain\Collection.fs"/>
|
||||
<Compile Include="Domain\Grave.fs"/>
|
||||
<Compile Include="SerialisedDomain.fs"/>
|
||||
<Compile Include="SerialisedCard.fs" />
|
||||
<Compile Include="SerialisedCollection.fs"/>
|
||||
<Compile Include="JsonDomain.fs" />
|
||||
<Compile Include="Base91.fs" />
|
||||
<Compile Include="Sqlite.fs"/>
|
||||
<Content Include="Examples\example-collection-conf.json"/>
|
||||
<Content Include="Examples\example-collection-models.json"/>
|
||||
<Content Include="Examples\example-collection-decks.json"/>
|
||||
<Content Include="Examples\example-collection-deck-conf.json"/>
|
||||
<Content Include="anki.schema.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.10"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
6
AnkiStatic.Lib/AssemblyInfo.fs
Normal file
6
AnkiStatic.Lib/AssemblyInfo.fs
Normal file
@@ -0,0 +1,6 @@
|
||||
module AssemblyInfo
|
||||
|
||||
open System.Runtime.CompilerServices
|
||||
|
||||
[<assembly : InternalsVisibleTo("AnkiStatic.Test")>]
|
||||
do ()
|
24
AnkiStatic.Lib/Base91.fs
Normal file
24
AnkiStatic.Lib/Base91.fs
Normal file
@@ -0,0 +1,24 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System.Text
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal Base91 =
|
||||
|
||||
// Replicating the Anki algorithm
|
||||
let private chars =
|
||||
"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"
|
||||
+ "0123456789"
|
||||
+ "!#$%&()*+,-./:;<=>?@[]^_`{|}~"
|
||||
|
||||
let toString (input : uint64) : string =
|
||||
let output = StringBuilder ()
|
||||
let mutable input = input
|
||||
|
||||
while input > 0uL do
|
||||
let modded = int (input % (uint64 chars.Length))
|
||||
let div = input / (uint64 chars.Length)
|
||||
input <- div
|
||||
output.Append chars.[modded] |> ignore
|
||||
|
||||
output.ToString ()
|
99
AnkiStatic.Lib/Domain/Card.fs
Normal file
99
AnkiStatic.Lib/Domain/Card.fs
Normal file
@@ -0,0 +1,99 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type CardType =
|
||||
| New
|
||||
| Learning
|
||||
| Review
|
||||
| Relearning
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| CardType.New -> 0
|
||||
| CardType.Learning -> 1
|
||||
| CardType.Review -> 2
|
||||
| CardType.Relearning -> 3
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type Queue =
|
||||
| UserBuried
|
||||
| SchedulerBuried
|
||||
| Buried
|
||||
| Suspended
|
||||
| New
|
||||
| Learning
|
||||
| Review
|
||||
| InLearning
|
||||
| Preview
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| Queue.UserBuried -> -3
|
||||
// Yes, there's an overlap. The two scheduling algorithms
|
||||
// interpret -2 in a slightly different sense.
|
||||
| Queue.SchedulerBuried
|
||||
| Queue.Buried -> -2
|
||||
| Queue.Suspended -> -1
|
||||
| Queue.New -> 0
|
||||
| Queue.Learning -> 1
|
||||
| Queue.Review -> 2
|
||||
| Queue.InLearning -> 3
|
||||
| Queue.Preview -> 4
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type Interval =
|
||||
| Seconds of int
|
||||
| Days of int
|
||||
| Unset
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| Interval.Unset -> 0
|
||||
| Interval.Days d -> d
|
||||
| Interval.Seconds s -> -s
|
||||
|
||||
/// Ease of 1000 means "no bias".
|
||||
/// Ease of 2500 means "this is 2.5x easier", so intervals get 2.5xed.
|
||||
[<Measure>]
|
||||
type ease
|
||||
|
||||
/// We don't model cards in a filtered deck.
|
||||
type Card<'Note, 'Deck> =
|
||||
{
|
||||
CreationDate : DateTimeOffset
|
||||
NotesId : 'Note
|
||||
DeckId : 'Deck
|
||||
Ordinal : int
|
||||
ModificationDate : DateTimeOffset
|
||||
UpdateSequenceNumber : int
|
||||
Type : CardType
|
||||
Queue : Queue
|
||||
Due : int
|
||||
Interval : Interval
|
||||
EaseFactor : int<ease>
|
||||
NumberOfReviews : int
|
||||
NumberOfLapses : int
|
||||
Left : int
|
||||
OriginalDue : int
|
||||
/// A client-defined extra bitmask.
|
||||
Flags : int
|
||||
/// Currently unused.
|
||||
Data : string
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type NewCardDistribution =
|
||||
/// See new cards mixed in with reviews of old cards
|
||||
| Distribute
|
||||
/// See new cards after reviewing old cards
|
||||
| Last
|
||||
/// See new cards before reviewing old cards
|
||||
| First
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| NewCardDistribution.Distribute -> 0
|
||||
| NewCardDistribution.Last -> 1
|
||||
| NewCardDistribution.First -> 2
|
53
AnkiStatic.Lib/Domain/Collection.fs
Normal file
53
AnkiStatic.Lib/Domain/Collection.fs
Normal file
@@ -0,0 +1,53 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
|
||||
type Collection<'Model, 'Deck> =
|
||||
{
|
||||
CreationDate : DateTimeOffset
|
||||
LastModified : DateTimeOffset
|
||||
LastSchemaModification : DateTimeOffset
|
||||
Version : int
|
||||
/// Apparently unused and always 0
|
||||
Dirty : int
|
||||
UpdateSequenceNumber : int
|
||||
LastSync : DateTimeOffset
|
||||
Configuration : CollectionConfiguration<'Model, 'Deck>
|
||||
Models : Map<DateTimeOffset, ModelConfiguration<'Deck>>
|
||||
Decks : Map<DateTimeOffset, Deck>
|
||||
DeckConfigurations : Map<DateTimeOffset, DeckConfiguration>
|
||||
Tags : string
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Collection =
|
||||
|
||||
let getJsonDeckString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
|
||||
col.Decks
|
||||
|> Map.toSeq
|
||||
|> Seq.map (fun (dto, deck) ->
|
||||
let timestamp = dto.ToUnixTimeMilliseconds ()
|
||||
Deck.toJson timestamp None deck |> sprintf "\"%i\": %s" timestamp
|
||||
)
|
||||
|> String.concat ","
|
||||
|> sprintf "{%s}"
|
||||
|
||||
let getDeckConfigurationString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
|
||||
col.DeckConfigurations
|
||||
|> Map.toSeq
|
||||
|> Seq.map (fun (dto, conf) ->
|
||||
let timestamp = dto.ToUnixTimeMilliseconds ()
|
||||
DeckConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp
|
||||
)
|
||||
|> String.concat ","
|
||||
|> sprintf "{%s}"
|
||||
|
||||
let getJsonModelString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
|
||||
col.Models
|
||||
|> Map.toSeq
|
||||
|> Seq.map (fun (dto, conf) ->
|
||||
let timestamp = dto.ToUnixTimeMilliseconds ()
|
||||
ModelConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp
|
||||
)
|
||||
|> String.concat ","
|
||||
|> sprintf "{%s}"
|
50
AnkiStatic.Lib/Domain/CollectionConfiguration.fs
Normal file
50
AnkiStatic.Lib/Domain/CollectionConfiguration.fs
Normal file
@@ -0,0 +1,50 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.Json
|
||||
|
||||
type CollectionConfiguration<'Model, 'Deck> =
|
||||
{
|
||||
CurrentDeck : 'Deck option
|
||||
ActiveDecks : 'Deck list
|
||||
NewSpread : NewCardDistribution
|
||||
CollapseTime : int
|
||||
TimeLimit : TimeSpan
|
||||
EstimateTimes : bool
|
||||
ShowDueCounts : bool
|
||||
CurrentModel : 'Model
|
||||
NextPosition : int
|
||||
/// This has some specifically allowed values, but :shrug:
|
||||
SortType : string
|
||||
SortBackwards : bool
|
||||
/// Value of "when adding, default to current deck"
|
||||
AddToCurrent : bool
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module CollectionConfiguration =
|
||||
let toJsonString (this : CollectionConfiguration<DateTimeOffset, DateTimeOffset>) : string =
|
||||
let currentDeckString =
|
||||
match this.CurrentDeck with
|
||||
| None -> ""
|
||||
| Some d -> sprintf "\"curDeck\": %i," (d.ToUnixTimeMilliseconds ())
|
||||
|
||||
let activeDecks =
|
||||
this.ActiveDecks
|
||||
|> List.map (fun dto -> dto.ToUnixTimeSeconds().ToString ())
|
||||
|> String.concat ","
|
||||
|
||||
$"""{{
|
||||
"nextPos": %i{this.NextPosition},
|
||||
"estTimes": %b{this.EstimateTimes},
|
||||
"activeDecks": [%s{activeDecks}],
|
||||
"sortType": %s{JsonSerializer.Serialize this.SortType},
|
||||
"timeLim": %i{int this.TimeLimit.TotalSeconds},
|
||||
"sortBackwards": %b{this.SortBackwards},
|
||||
"addToCur": %b{this.AddToCurrent},
|
||||
%s{currentDeckString}
|
||||
"newSpread": %i{this.NewSpread.ToInteger ()},
|
||||
"dueCounts": %b{this.ShowDueCounts},
|
||||
"curModel": "%i{this.CurrentModel.ToUnixTimeMilliseconds ()}",
|
||||
"collapseTime": %i{this.CollapseTime}
|
||||
}}"""
|
58
AnkiStatic.Lib/Domain/Deck.fs
Normal file
58
AnkiStatic.Lib/Domain/Deck.fs
Normal file
@@ -0,0 +1,58 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.Json
|
||||
|
||||
type Deck =
|
||||
{
|
||||
// We'll assume newToday, revToday, lrnToday, timeToday are all [0,0]
|
||||
Name : string
|
||||
ExtendedReviewLimit : int option
|
||||
ExtendedNewCardLimit : int option
|
||||
UpdateSequenceNumber : int
|
||||
Collapsed : bool
|
||||
BrowserCollapsed : bool
|
||||
Description : string
|
||||
LastModified : DateTimeOffset
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Deck =
|
||||
let toJson (id : int64) (model : DateTimeOffset option) (this : Deck) : string =
|
||||
let extendRev =
|
||||
match this.ExtendedReviewLimit with
|
||||
| None -> ""
|
||||
| Some rev -> sprintf "\"extendRev\": %i," rev
|
||||
|
||||
let extendNew =
|
||||
match this.ExtendedNewCardLimit with
|
||||
| None -> ""
|
||||
| Some lim -> sprintf "\"extendNew\": %i," lim
|
||||
|
||||
let model =
|
||||
match model with
|
||||
| None -> ""
|
||||
| Some model -> model.ToUnixTimeMilliseconds () |> sprintf "\"mod\": %i,"
|
||||
|
||||
// TODO: what is `conf`?
|
||||
$"""{{
|
||||
"name": %s{JsonSerializer.Serialize this.Name},
|
||||
"desc": %s{JsonSerializer.Serialize this.Description},
|
||||
%s{extendRev}
|
||||
"usn": %i{this.UpdateSequenceNumber},
|
||||
"collapsed": %b{this.Collapsed},
|
||||
"newToday": [0,0],
|
||||
"timeToday": [0,0],
|
||||
"revToday": [0,0],
|
||||
"lrnToday": [0,0],
|
||||
"dyn": 0,
|
||||
%s{model}
|
||||
%s{extendNew}
|
||||
"conf": 1,
|
||||
"id": %i{id},
|
||||
"mod": %i{this.LastModified.ToUnixTimeSeconds ()}
|
||||
}}"""
|
||||
|
||||
|
||||
[<Measure>]
|
||||
type deck
|
130
AnkiStatic.Lib/Domain/DeckConfiguration.fs
Normal file
130
AnkiStatic.Lib/Domain/DeckConfiguration.fs
Normal file
@@ -0,0 +1,130 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.Json
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type LeechAction =
|
||||
| Suspend
|
||||
| Mark
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| LeechAction.Suspend -> 0
|
||||
| LeechAction.Mark -> 1
|
||||
|
||||
type LapseConfiguration =
|
||||
{
|
||||
Delays : int list
|
||||
LeechAction : LeechAction
|
||||
LeechFails : int
|
||||
MinInterval : int
|
||||
Multiplier : float
|
||||
}
|
||||
|
||||
static member toJson (this : LapseConfiguration) : string =
|
||||
let delays =
|
||||
this.Delays
|
||||
|> Seq.map (fun (i : int) -> i.ToString ())
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
let mult =
|
||||
if this.Multiplier <> 0.0 then
|
||||
failwith "can't yet handle this"
|
||||
else
|
||||
"0"
|
||||
|
||||
$"""{{
|
||||
"leechFails": %i{this.LeechFails},
|
||||
"minInt": %i{this.MinInterval},
|
||||
"delays": %s{delays},
|
||||
"leechAction": %i{this.LeechAction.ToInteger ()},
|
||||
"mult": %s{mult}
|
||||
}}"""
|
||||
|
||||
type IntervalConfiguration =
|
||||
{
|
||||
Good : int
|
||||
Easy : int
|
||||
Unused : int
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
type NewCardOrder =
|
||||
| Random
|
||||
| Due
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| NewCardOrder.Random -> 0
|
||||
| NewCardOrder.Due -> 1
|
||||
|
||||
type NewCardConfiguration =
|
||||
{
|
||||
Bury : bool
|
||||
Delays : int list
|
||||
InitialEase : int<ease>
|
||||
Intervals : IntervalConfiguration
|
||||
Order : NewCardOrder
|
||||
MaxNewPerDay : int
|
||||
/// Apparently unused; leave this as `true`
|
||||
Separate : bool
|
||||
}
|
||||
|
||||
static member toJson (this : NewCardConfiguration) : string =
|
||||
let ints =
|
||||
[ this.Intervals.Good ; this.Intervals.Easy ; this.Intervals.Unused ]
|
||||
|> Seq.map (fun (s : int) -> s.ToString ())
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
let delays =
|
||||
this.Delays
|
||||
|> Seq.map (fun (s : int) -> s.ToString ())
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
$"""{{
|
||||
"perDay": %i{this.MaxNewPerDay},
|
||||
"delays": %s{delays},
|
||||
"separate": %b{this.Separate},
|
||||
"ints": %s{ints},
|
||||
"initialFactor": %i{this.InitialEase},
|
||||
"order": %i{this.Order.ToInteger ()}
|
||||
}}"""
|
||||
|
||||
type DeckConfiguration =
|
||||
{
|
||||
AutoPlay : bool
|
||||
Lapse : LapseConfiguration
|
||||
MaxTaken : TimeSpan
|
||||
LastModified : DateTimeOffset
|
||||
Name : string
|
||||
New : NewCardConfiguration
|
||||
ReplayQuestionAudioWithAnswer : bool
|
||||
Review : ReviewConfiguration
|
||||
ShowTimer : bool
|
||||
UpdateSequenceNumber : int
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module DeckConfiguration =
|
||||
|
||||
let toJson (id : int64) (conf : DeckConfiguration) : string =
|
||||
$"""{{
|
||||
"name": {JsonSerializer.Serialize conf.Name},
|
||||
"replayq": %b{conf.ReplayQuestionAudioWithAnswer},
|
||||
"lapse": %s{LapseConfiguration.toJson conf.Lapse},
|
||||
"rev": %s{ReviewConfiguration.toJson conf.Review},
|
||||
"timer": %i{if conf.ShowTimer then 1 else 0},
|
||||
"maxTaken": %i{int conf.MaxTaken.TotalSeconds},
|
||||
"usn": %i{conf.UpdateSequenceNumber},
|
||||
"new": %s{NewCardConfiguration.toJson conf.New},
|
||||
"mod": %i{conf.LastModified.ToUnixTimeMilliseconds ()},
|
||||
"id": %i{id},
|
||||
"autoplay": %b{conf.AutoPlay}
|
||||
}}"""
|
||||
|
||||
[<Measure>]
|
||||
type deckOption
|
19
AnkiStatic.Lib/Domain/Grave.fs
Normal file
19
AnkiStatic.Lib/Domain/Grave.fs
Normal file
@@ -0,0 +1,19 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
type GraveType =
|
||||
| Card
|
||||
| Note
|
||||
| Deck
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| GraveType.Card -> 0
|
||||
| GraveType.Note -> 1
|
||||
| GraveType.Deck -> 2
|
||||
|
||||
type Grave =
|
||||
{
|
||||
UpdateSequenceNumber : int
|
||||
ObjectId : int
|
||||
Type : GraveType
|
||||
}
|
143
AnkiStatic.Lib/Domain/Model.fs
Normal file
143
AnkiStatic.Lib/Domain/Model.fs
Normal file
@@ -0,0 +1,143 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.Json
|
||||
|
||||
type CardTemplate<'Deck> =
|
||||
{
|
||||
AnswerFormat : string
|
||||
BrowserAnswerFormat : string
|
||||
BrowserQuestionFormat : string
|
||||
DeckOverride : 'Deck option
|
||||
Name : string
|
||||
Ord : int
|
||||
QuestionFormat : string
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module CardTemplate =
|
||||
let toJson (this : CardTemplate<DateTimeOffset>) : string =
|
||||
let did =
|
||||
match this.DeckOverride with
|
||||
| None -> "null"
|
||||
| Some did -> sprintf "%i" (did.ToUnixTimeMilliseconds ())
|
||||
|
||||
$"""{{
|
||||
"afmt": %s{JsonSerializer.Serialize this.AnswerFormat},
|
||||
"name": %s{JsonSerializer.Serialize this.Name},
|
||||
"qfmt": %s{JsonSerializer.Serialize this.QuestionFormat},
|
||||
"did": %s{did},
|
||||
"ord": %i{this.Ord},
|
||||
"bafmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat},
|
||||
"bqfmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat}
|
||||
}}"""
|
||||
|
||||
type ModelField =
|
||||
{
|
||||
/// E.g. "Arial"
|
||||
Font : string
|
||||
/// Docs suggest this is unused
|
||||
Media : string list
|
||||
Name : string
|
||||
/// For some reason a ModelField is intended to be stored in an
|
||||
/// array, but *also* tagged with its index in that array :shrug:
|
||||
Ord : int
|
||||
/// Whether text should display right-to-left
|
||||
RightToLeft : bool
|
||||
FontSize : int
|
||||
Sticky : bool
|
||||
}
|
||||
|
||||
static member toJson (this : ModelField) : string =
|
||||
let media =
|
||||
this.Media
|
||||
|> Seq.map JsonSerializer.Serialize
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
$"""{{
|
||||
"size": %i{this.FontSize},
|
||||
"name": %s{JsonSerializer.Serialize this.Name},
|
||||
"media": %s{media},
|
||||
"rtl": %b{this.RightToLeft},
|
||||
"ord": %i{this.Ord},
|
||||
"font": %s{JsonSerializer.Serialize this.Font},
|
||||
"sticky": %b{this.Sticky}
|
||||
}}"""
|
||||
|
||||
|
||||
type ModelType =
|
||||
| Standard
|
||||
| Cloze
|
||||
|
||||
member this.ToInteger () =
|
||||
match this with
|
||||
| ModelType.Standard -> 0
|
||||
| ModelType.Cloze -> 1
|
||||
|
||||
type ModelConfiguration<'Deck> =
|
||||
{
|
||||
Css : string
|
||||
DefaultDeckId : 'Deck
|
||||
Fields : ModelField list
|
||||
/// String which is added to terminate LaTeX expressions
|
||||
LatexPost : string
|
||||
LatexPre : string
|
||||
LastModification : DateTimeOffset
|
||||
Name : string
|
||||
// I've omitted `req` which is unused in modern clients
|
||||
/// Which field the browser uses to sort by
|
||||
SortField : int
|
||||
/// Unused, should always be empty
|
||||
Tags : string list
|
||||
Templates : CardTemplate<'Deck> list
|
||||
Type : ModelType
|
||||
UpdateSequenceNumber : int
|
||||
/// Unused, should always be empty
|
||||
Version : string list
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module ModelConfiguration =
|
||||
let toJson (id : int64) (this : ModelConfiguration<DateTimeOffset>) : string =
|
||||
let vers =
|
||||
this.Version
|
||||
|> Seq.map JsonSerializer.Serialize
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
let tags =
|
||||
this.Tags
|
||||
|> Seq.map JsonSerializer.Serialize
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
let flds =
|
||||
this.Fields |> Seq.map ModelField.toJson |> String.concat "," |> sprintf "[%s]"
|
||||
|
||||
let tmpls =
|
||||
this.Templates
|
||||
|> Seq.map CardTemplate.toJson
|
||||
|> String.concat ","
|
||||
|> sprintf "[%s]"
|
||||
|
||||
$"""{{
|
||||
"vers": %s{vers},
|
||||
"name": %s{JsonSerializer.Serialize this.Name},
|
||||
"tags": %s{tags},
|
||||
"did": %i{this.DefaultDeckId.ToUnixTimeMilliseconds ()},
|
||||
"usn": %i{this.UpdateSequenceNumber},
|
||||
"flds": %s{flds},
|
||||
"sortf": %i{this.SortField},
|
||||
"tmpls": %s{tmpls},
|
||||
"latexPre": %s{JsonSerializer.Serialize this.LatexPre},
|
||||
"latexPost": %s{JsonSerializer.Serialize this.LatexPost},
|
||||
"type": %i{this.Type.ToInteger ()},
|
||||
"id": %i{id},
|
||||
"css": %s{JsonSerializer.Serialize this.Css},
|
||||
"mod": %i{this.LastModification.ToUnixTimeSeconds ()}
|
||||
}}"""
|
||||
|
||||
/// Identifies a type of note (e.g. "Cloze").
|
||||
[<Measure>]
|
||||
type model
|
43
AnkiStatic.Lib/Domain/Note.fs
Normal file
43
AnkiStatic.Lib/Domain/Note.fs
Normal file
@@ -0,0 +1,43 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Security.Cryptography
|
||||
open System.Text
|
||||
|
||||
type Note<'Model> =
|
||||
{
|
||||
Guid : uint64
|
||||
ModelId : 'Model
|
||||
LastModified : DateTimeOffset
|
||||
UpdateSequenceNumber : int
|
||||
/// Serialised space-separated as a string, with a space at the start and end.
|
||||
Tags : string list
|
||||
/// Serialised as a string separated by the 0x1f character
|
||||
Fields : string list
|
||||
/// In the Sqlite table, this is an int field.
|
||||
/// Sqlite is dynamically typed and accepts strings in an int field.
|
||||
/// But it will sort "correctly" in the sense that integers are compared as integers
|
||||
/// for the purpose of sorting in this way.
|
||||
SortField : Choice<string, int>
|
||||
/// Unused
|
||||
Flags : int
|
||||
/// Unused
|
||||
Data : string
|
||||
}
|
||||
|
||||
member this.Checksum : uint =
|
||||
let fromBase256 (firstCount : int) (bytes : byte[]) : uint =
|
||||
let mutable answer = 0u
|
||||
|
||||
for b = 0 to firstCount - 1 do
|
||||
answer <- answer * 256u
|
||||
answer <- answer + uint bytes.[b]
|
||||
|
||||
answer
|
||||
|
||||
use sha1 = SHA1.Create ()
|
||||
// TODO: in the wild, this actually strips HTML first
|
||||
this.Fields.[0] |> Encoding.UTF8.GetBytes |> sha1.ComputeHash |> fromBase256 4
|
||||
|
||||
[<Measure>]
|
||||
type note
|
25
AnkiStatic.Lib/Domain/Review.fs
Normal file
25
AnkiStatic.Lib/Domain/Review.fs
Normal file
@@ -0,0 +1,25 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
|
||||
type ReviewConfiguration =
|
||||
{
|
||||
Bury : bool
|
||||
EasinessPerEasyReview : float
|
||||
Fuzz : float
|
||||
IntervalFactor : int
|
||||
MaxInterval : TimeSpan
|
||||
/// Unused; set to 1
|
||||
MinSpace : int
|
||||
PerDay : int
|
||||
}
|
||||
|
||||
static member toJson (this : ReviewConfiguration) : string =
|
||||
$"""{{
|
||||
"perDay": %i{this.PerDay},
|
||||
"ivlFct": %i{this.IntervalFactor},
|
||||
"maxIvl": %i{int this.MaxInterval.TotalDays},
|
||||
"minSpace": %i{this.MinSpace},
|
||||
"ease4": %f{this.EasinessPerEasyReview},
|
||||
"fuzz": %f{this.Fuzz}
|
||||
}}"""
|
16
AnkiStatic.Lib/Examples/example-collection-conf.json
Normal file
16
AnkiStatic.Lib/Examples/example-collection-conf.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"nextPos": 1,
|
||||
"estTimes": true,
|
||||
"activeDecks": [
|
||||
1
|
||||
],
|
||||
"sortType": "noteFld",
|
||||
"timeLim": 0,
|
||||
"sortBackwards": false,
|
||||
"addToCur": true,
|
||||
"curDeck": 1,
|
||||
"newSpread": 0,
|
||||
"dueCounts": true,
|
||||
"curModel": "1373473028447",
|
||||
"collapseTime": 1200
|
||||
}
|
44
AnkiStatic.Lib/Examples/example-collection-deck-conf.json
Normal file
44
AnkiStatic.Lib/Examples/example-collection-deck-conf.json
Normal file
@@ -0,0 +1,44 @@
|
||||
{
|
||||
"1": {
|
||||
"name": "Default",
|
||||
"replayq": true,
|
||||
"lapse": {
|
||||
"leechFails": 8,
|
||||
"minInt": 1,
|
||||
"delays": [
|
||||
10
|
||||
],
|
||||
"leechAction": 0,
|
||||
"mult": 0
|
||||
},
|
||||
"rev": {
|
||||
"perDay": 100,
|
||||
"ivlFct": 1,
|
||||
"maxIvl": 36500,
|
||||
"minSpace": 1,
|
||||
"ease4": 1.3,
|
||||
"fuzz": 0.05
|
||||
},
|
||||
"timer": 0,
|
||||
"maxTaken": 60,
|
||||
"usn": 0,
|
||||
"new": {
|
||||
"perDay": 20,
|
||||
"delays": [
|
||||
1,
|
||||
10
|
||||
],
|
||||
"separate": true,
|
||||
"ints": [
|
||||
1,
|
||||
4,
|
||||
7
|
||||
],
|
||||
"initialFactor": 2500,
|
||||
"order": 1
|
||||
},
|
||||
"mod": 0,
|
||||
"id": 1,
|
||||
"autoplay": true
|
||||
}
|
||||
}
|
59
AnkiStatic.Lib/Examples/example-collection-decks.json
Normal file
59
AnkiStatic.Lib/Examples/example-collection-decks.json
Normal file
@@ -0,0 +1,59 @@
|
||||
{
|
||||
"1": {
|
||||
"desc": "",
|
||||
"name": "Default",
|
||||
"extendRev": 50,
|
||||
"usn": 0,
|
||||
"collapsed": false,
|
||||
"newToday": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"timeToday": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"dyn": 0,
|
||||
"extendNew": 10,
|
||||
"conf": 1,
|
||||
"revToday": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"lrnToday": [
|
||||
0,
|
||||
0
|
||||
],
|
||||
"id": 1,
|
||||
"mod": 1373473028
|
||||
},
|
||||
"1369508778847": {
|
||||
"name": "Analysis",
|
||||
"extendRev": 50,
|
||||
"usn": -1,
|
||||
"collapsed": false,
|
||||
"mid": "1369511891515",
|
||||
"newToday": [
|
||||
219.0,
|
||||
0
|
||||
],
|
||||
"timeToday": [
|
||||
219.0,
|
||||
0
|
||||
],
|
||||
"dyn": 0,
|
||||
"extendNew": 10,
|
||||
"conf": 1,
|
||||
"revToday": [
|
||||
219.0,
|
||||
0
|
||||
],
|
||||
"lrnToday": [
|
||||
219.0,
|
||||
0
|
||||
],
|
||||
"id": 1369508778847,
|
||||
"mod": 1373402705,
|
||||
"desc": ""
|
||||
}
|
||||
}
|
368
AnkiStatic.Lib/Examples/example-collection-models.json
Normal file
368
AnkiStatic.Lib/Examples/example-collection-models.json
Normal file
@@ -0,0 +1,368 @@
|
||||
{
|
||||
"1373473028441": {
|
||||
"vers": [],
|
||||
"name": "Basic (optional reversed card)",
|
||||
"tags": [],
|
||||
"did": 1,
|
||||
"usn": -1,
|
||||
"req": [
|
||||
[
|
||||
0,
|
||||
"all",
|
||||
[
|
||||
0
|
||||
]
|
||||
],
|
||||
[
|
||||
1,
|
||||
"all",
|
||||
[
|
||||
1,
|
||||
2
|
||||
]
|
||||
]
|
||||
],
|
||||
"flds": [
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Front",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
},
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Back",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
},
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Add Reverse",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 2,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||
"name": "Card 1",
|
||||
"qfmt": "{{Front}}",
|
||||
"did": null,
|
||||
"ord": 0,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
},
|
||||
{
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
|
||||
"name": "Card 2",
|
||||
"qfmt": "{{#Add Reverse}}{{Back}}{{/Add Reverse}}",
|
||||
"did": null,
|
||||
"ord": 1,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 0,
|
||||
"id": "1373473028441",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"mod": 1373473028
|
||||
},
|
||||
"1373473028440": {
|
||||
"vers": [],
|
||||
"name": "Cloze",
|
||||
"tags": [],
|
||||
"did": 1,
|
||||
"usn": -1,
|
||||
"flds": [
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Text",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
},
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Extra",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"afmt": "{{cloze:Text}}<br>\n{{Extra}}",
|
||||
"name": "Cloze",
|
||||
"qfmt": "{{cloze:Text}}",
|
||||
"did": null,
|
||||
"ord": 0,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 1,
|
||||
"id": "1373473028440",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.cloze {\n font-weight: bold;\n color: blue;\n}",
|
||||
"mod": 1373473028
|
||||
},
|
||||
"1373473028447": {
|
||||
"vers": [],
|
||||
"name": "Basic",
|
||||
"tags": [],
|
||||
"did": 1,
|
||||
"usn": -1,
|
||||
"req": [
|
||||
[
|
||||
0,
|
||||
"all",
|
||||
[
|
||||
0
|
||||
]
|
||||
]
|
||||
],
|
||||
"flds": [
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Front",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
},
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Back",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||
"name": "Card 1",
|
||||
"qfmt": "{{Front}}",
|
||||
"did": null,
|
||||
"ord": 0,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 0,
|
||||
"id": "1373473028447",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"mod": 1373473028
|
||||
},
|
||||
"1373473028445": {
|
||||
"vers": [],
|
||||
"name": "Basic (and reversed card)",
|
||||
"tags": [],
|
||||
"did": 1,
|
||||
"usn": -1,
|
||||
"req": [
|
||||
[
|
||||
0,
|
||||
"all",
|
||||
[
|
||||
0
|
||||
]
|
||||
],
|
||||
[
|
||||
1,
|
||||
"all",
|
||||
[
|
||||
1
|
||||
]
|
||||
]
|
||||
],
|
||||
"flds": [
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Front",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
},
|
||||
{
|
||||
"size": 20,
|
||||
"name": "Back",
|
||||
"media": [],
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"sticky": false
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||
"name": "Card 1",
|
||||
"qfmt": "{{Front}}",
|
||||
"did": null,
|
||||
"ord": 0,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
},
|
||||
{
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
|
||||
"name": "Card 2",
|
||||
"qfmt": "{{Back}}",
|
||||
"did": null,
|
||||
"ord": 1,
|
||||
"bafmt": "",
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 0,
|
||||
"id": "1373473028445",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"mod": 1373473028
|
||||
},
|
||||
"1369511891515": {
|
||||
"vers": [],
|
||||
"name": "Theorem/Proof",
|
||||
"tags": [],
|
||||
"did": 1373192002512,
|
||||
"usn": -1,
|
||||
"req": [
|
||||
[
|
||||
0,
|
||||
"all",
|
||||
[
|
||||
0
|
||||
]
|
||||
]
|
||||
],
|
||||
"flds": [
|
||||
{
|
||||
"name": "Theorem",
|
||||
"media": [],
|
||||
"sticky": false,
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"size": 20
|
||||
},
|
||||
{
|
||||
"name": "Proof idea",
|
||||
"media": [],
|
||||
"sticky": false,
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"size": 20
|
||||
},
|
||||
{
|
||||
"name": "Example",
|
||||
"media": [],
|
||||
"sticky": false,
|
||||
"rtl": false,
|
||||
"ord": 2,
|
||||
"font": "Arial",
|
||||
"size": 20
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": "{{Theorem}}",
|
||||
"did": null,
|
||||
"bafmt": "",
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Proof idea}}",
|
||||
"ord": 0,
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 0,
|
||||
"id": "1369511891515",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"mod": 1373453863
|
||||
},
|
||||
"1354566092435": {
|
||||
"vers": [],
|
||||
"name": "Basic",
|
||||
"tags": [],
|
||||
"did": 1369508778847,
|
||||
"usn": -1,
|
||||
"req": [
|
||||
[
|
||||
0,
|
||||
"all",
|
||||
[
|
||||
0
|
||||
]
|
||||
]
|
||||
],
|
||||
"flds": [
|
||||
{
|
||||
"name": "Front",
|
||||
"media": [],
|
||||
"sticky": false,
|
||||
"rtl": false,
|
||||
"ord": 0,
|
||||
"font": "Arial",
|
||||
"size": 20
|
||||
},
|
||||
{
|
||||
"name": "Back",
|
||||
"media": [],
|
||||
"sticky": false,
|
||||
"rtl": false,
|
||||
"ord": 1,
|
||||
"font": "Arial",
|
||||
"size": 20
|
||||
}
|
||||
],
|
||||
"sortf": 0,
|
||||
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||
"tmpls": [
|
||||
{
|
||||
"name": "Card 1",
|
||||
"qfmt": "{{Front}}",
|
||||
"did": null,
|
||||
"bafmt": "",
|
||||
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||
"ord": 0,
|
||||
"bqfmt": ""
|
||||
}
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"type": 0,
|
||||
"id": "1354566092435",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"mod": 1369674106
|
||||
}
|
||||
}
|
349
AnkiStatic.Lib/JsonDomain.fs
Normal file
349
AnkiStatic.Lib/JsonDomain.fs
Normal file
@@ -0,0 +1,349 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
|
||||
type private LeechActionJsonConverter () =
|
||||
inherit JsonConverter<LeechAction> ()
|
||||
|
||||
override this.Read (reader, _, options) =
|
||||
match reader.GetString().ToLowerInvariant () with
|
||||
| "suspend" -> LeechAction.Suspend
|
||||
| "mark" -> LeechAction.Mark
|
||||
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||
|
||||
override this.Write (writer, value, options) =
|
||||
match value with
|
||||
| LeechAction.Mark -> "mark"
|
||||
| LeechAction.Suspend -> "suspend"
|
||||
|> writer.WriteStringValue
|
||||
|
||||
type private NewCardOrderJsonConverter () =
|
||||
inherit JsonConverter<NewCardOrder> ()
|
||||
|
||||
override this.Read (reader, _, options) =
|
||||
match reader.GetString().ToLowerInvariant () with
|
||||
| "random" -> NewCardOrder.Random
|
||||
| "due" -> NewCardOrder.Due
|
||||
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||
|
||||
override this.Write (writer, value, options) =
|
||||
match value with
|
||||
| NewCardOrder.Random -> "random"
|
||||
| NewCardOrder.Due -> "due"
|
||||
|> writer.WriteStringValue
|
||||
|
||||
type private ModelTypeJsonConverter () =
|
||||
inherit JsonConverter<ModelType> ()
|
||||
|
||||
override this.Read (reader, _, options) =
|
||||
match reader.GetString().ToLowerInvariant () with
|
||||
| "standard" -> ModelType.Standard
|
||||
| "cloze" -> ModelType.Cloze
|
||||
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||
|
||||
override this.Write (writer, value, options) =
|
||||
match value with
|
||||
| ModelType.Standard -> "standard"
|
||||
| ModelType.Cloze -> "cloze"
|
||||
|> writer.WriteStringValue
|
||||
|
||||
type private NewCardDistributionJsonConverter () =
|
||||
inherit JsonConverter<NewCardDistribution> ()
|
||||
|
||||
override this.Read (reader, _, options) =
|
||||
match reader.GetString().ToLowerInvariant () with
|
||||
| "distribute" -> NewCardDistribution.Distribute
|
||||
| "first" -> NewCardDistribution.First
|
||||
| "last" -> NewCardDistribution.Last
|
||||
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||
|
||||
override this.Write (writer, value, options) =
|
||||
match value with
|
||||
| NewCardDistribution.Distribute -> "distribute"
|
||||
| NewCardDistribution.First -> "first"
|
||||
| NewCardDistribution.Last -> "last"
|
||||
|> writer.WriteStringValue
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module JsonCollection =
|
||||
type JsonTemplate =
|
||||
{
|
||||
AnswerFormat : string
|
||||
QuestionFormat : string
|
||||
Name : string
|
||||
BrowserAnswerFormat : string option
|
||||
BrowserQuestionFormat : string option
|
||||
}
|
||||
|
||||
static member ToInternal (this : JsonTemplate) : SerialisedCardTemplate =
|
||||
{
|
||||
AnswerFormat = this.AnswerFormat
|
||||
QuestionFormat = this.QuestionFormat
|
||||
Name = this.Name
|
||||
BrowserAnswerFormat = this.BrowserAnswerFormat |> Option.defaultValue ""
|
||||
BrowserQuestionFormat = this.BrowserQuestionFormat |> Option.defaultValue ""
|
||||
}
|
||||
|
||||
type JsonMetadata =
|
||||
{
|
||||
// [<JsonRequired>]
|
||||
CreationDate : DateTimeOffset
|
||||
// [<JsonRequired>]
|
||||
DefaultDeck : string
|
||||
// [<JsonRequired>]
|
||||
DefaultDeckConfiguration : SerialisedDeckConfiguration
|
||||
/// Map into the string deck names
|
||||
NonDefaultDecks : IReadOnlyDictionary<DateTimeOffset, string>
|
||||
NonDefaultDeckConfigurations : IReadOnlyDictionary<DateTimeOffset, SerialisedDeckConfiguration>
|
||||
Tags : string
|
||||
// [<JsonRequired>]
|
||||
DefaultModelName : string
|
||||
SortBackwards : bool option
|
||||
ShowDueCounts : bool option
|
||||
NewSpread : NewCardDistribution
|
||||
EstimateTimes : bool option
|
||||
TimeLimitSeconds : int option
|
||||
CollapseTimeSeconds : int option
|
||||
}
|
||||
|
||||
type JsonNote =
|
||||
{
|
||||
Tags : string list option
|
||||
// [<JsonRequired>]
|
||||
SortFieldValue : string
|
||||
// [<JsonRequired>]
|
||||
AdditionalFieldValues : string list
|
||||
CreationDate : DateTimeOffset option
|
||||
// [<JsonRequired>]
|
||||
Model : string
|
||||
}
|
||||
|
||||
static member internal ToInternal
|
||||
(deck : SerialisedDeck)
|
||||
(models : Map<string, SerialisedModel>)
|
||||
(this : JsonNote)
|
||||
: SerialisedNote
|
||||
=
|
||||
{
|
||||
Deck = deck
|
||||
CreationDate = this.CreationDate |> Option.defaultValue DateTimeOffset.UnixEpoch
|
||||
Model = models.[this.Model]
|
||||
Tags = this.Tags |> Option.defaultValue []
|
||||
ValueOfSortField = this.SortFieldValue
|
||||
ValuesOfAdditionalFields = this.AdditionalFieldValues
|
||||
}
|
||||
|
||||
type JsonDeck =
|
||||
{
|
||||
ExtendedReviewLimit : int option
|
||||
ExtendedNewCardLimit : int option
|
||||
Collapsed : bool option
|
||||
BrowserCollapsed : bool option
|
||||
// [<JsonRequired>]
|
||||
Description : string
|
||||
// [<JsonRequired>]
|
||||
Notes : JsonNote list
|
||||
}
|
||||
|
||||
static member internal ToInternal (name : string) (deck : JsonDeck) : SerialisedDeck =
|
||||
{
|
||||
Name = name
|
||||
ExtendedReviewLimit = deck.ExtendedReviewLimit
|
||||
ExtendedNewCardLimit = deck.ExtendedNewCardLimit
|
||||
Collapsed = deck.Collapsed |> Option.defaultValue false
|
||||
BrowserCollapsed = deck.BrowserCollapsed |> Option.defaultValue false
|
||||
Description = deck.Description
|
||||
}
|
||||
|
||||
type JsonField =
|
||||
{
|
||||
// [<JsonRequired>]
|
||||
DisplayName : string
|
||||
Font : string option
|
||||
RightToLeft : bool option
|
||||
FontSize : int option
|
||||
Sticky : bool option
|
||||
}
|
||||
|
||||
static member internal ToInternal (field : JsonField) : SerialisedModelField =
|
||||
{
|
||||
Font = field.Font |> Option.defaultValue "Arial"
|
||||
Name = field.DisplayName
|
||||
RightToLeft = field.RightToLeft |> Option.defaultValue false
|
||||
FontSize = field.FontSize |> Option.defaultValue 20
|
||||
Sticky = field.Sticky |> Option.defaultValue false
|
||||
}
|
||||
|
||||
type JsonModel =
|
||||
{
|
||||
Css : string option
|
||||
/// Name of a field
|
||||
// [<JsonRequired>]
|
||||
SortField : string
|
||||
// [<JsonRequired>]
|
||||
AdditionalFields : string list
|
||||
LatexPost : string option
|
||||
LatexPre : string option
|
||||
// TODO: is this required?
|
||||
// [<JsonRequired>]
|
||||
Name : string
|
||||
// [<JsonRequired>]
|
||||
Templates : string list
|
||||
// [<JsonRequired>]
|
||||
Type : ModelType
|
||||
DefaultDeck : string option
|
||||
ModificationTime : DateTimeOffset
|
||||
}
|
||||
|
||||
static member internal ToInternal
|
||||
(defaultDeck : SerialisedDeck)
|
||||
(standardTemplates : IReadOnlyDictionary<string, SerialisedCardTemplate>)
|
||||
(clozeTemplates : IReadOnlyDictionary<string, SerialisedCardTemplate>)
|
||||
(decks : Map<string, SerialisedDeck>)
|
||||
(fields : Map<string, SerialisedModelField>)
|
||||
(this : JsonModel)
|
||||
: SerialisedModel
|
||||
=
|
||||
{
|
||||
Css =
|
||||
this.Css
|
||||
|> Option.defaultValue (
|
||||
JsonSerializer.Serialize
|
||||
".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n"
|
||||
)
|
||||
AdditionalFields = this.AdditionalFields |> List.map (fun field -> Map.find field fields)
|
||||
LatexPost =
|
||||
this.LatexPost
|
||||
|> Option.defaultValue (JsonSerializer.Serialize @"\end{document}")
|
||||
LatexPre =
|
||||
this.LatexPre
|
||||
|> Option.defaultValue (
|
||||
JsonSerializer.Serialize
|
||||
"\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n"
|
||||
)
|
||||
Name = this.Name
|
||||
SortField = fields.[this.SortField]
|
||||
Templates =
|
||||
match this.Type with
|
||||
| ModelType.Cloze -> this.Templates |> List.map (fun t -> clozeTemplates.[t])
|
||||
| ModelType.Standard -> this.Templates |> List.map (fun t -> standardTemplates.[t])
|
||||
Type = this.Type
|
||||
DefaultDeck =
|
||||
match this.DefaultDeck with
|
||||
| None -> defaultDeck
|
||||
| Some deck -> decks.[deck]
|
||||
}
|
||||
|
||||
type JsonCollection =
|
||||
{
|
||||
// [<JsonRequired>]
|
||||
Metadata : JsonMetadata
|
||||
// [<JsonRequired>]
|
||||
StandardTemplates : IReadOnlyDictionary<string, JsonTemplate>
|
||||
// [<JsonRequired>]
|
||||
ClozeTemplates : IReadOnlyDictionary<string, JsonTemplate>
|
||||
/// Map of name to deck
|
||||
// [<JsonRequired>]
|
||||
Decks : IReadOnlyDictionary<string, JsonDeck>
|
||||
// [<JsonRequired>]
|
||||
Fields : IReadOnlyDictionary<string, JsonField>
|
||||
// [<JsonRequired>]
|
||||
Models : IReadOnlyDictionary<string, JsonModel>
|
||||
}
|
||||
|
||||
let internal deserialise (s : string) : JsonCollection =
|
||||
let opts = JsonSerializerOptions ()
|
||||
opts.Converters.Add (LeechActionJsonConverter ())
|
||||
opts.Converters.Add (NewCardDistributionJsonConverter ())
|
||||
opts.Converters.Add (NewCardOrderJsonConverter ())
|
||||
opts.Converters.Add (ModelTypeJsonConverter ())
|
||||
opts.PropertyNameCaseInsensitive <- true
|
||||
JsonSerializer.Deserialize (s, opts)
|
||||
|
||||
let toInternal (collection : JsonCollection) : SerialisedCollection * SerialisedNote list =
|
||||
let decks =
|
||||
collection.Decks
|
||||
|> Seq.map (fun (KeyValue (deckName, deck)) -> deckName, JsonDeck.ToInternal deckName deck)
|
||||
|> Map.ofSeq
|
||||
|
||||
let fields =
|
||||
collection.Fields
|
||||
|> Seq.map (fun (KeyValue (fieldName, field)) -> fieldName, JsonField.ToInternal field)
|
||||
|> Map.ofSeq
|
||||
|
||||
let standardTemplates =
|
||||
collection.StandardTemplates
|
||||
|> Seq.map (fun (KeyValue (key, template)) -> key, JsonTemplate.ToInternal template)
|
||||
|> Map.ofSeq
|
||||
|
||||
let clozeTemplates =
|
||||
collection.ClozeTemplates
|
||||
|> Seq.map (fun (KeyValue (key, template)) -> key, JsonTemplate.ToInternal template)
|
||||
|> Map.ofSeq
|
||||
|
||||
let defaultDeck = decks.[collection.Metadata.DefaultDeck]
|
||||
|
||||
let models : Map<string, SerialisedModel> =
|
||||
collection.Models
|
||||
|> Seq.map (fun (KeyValue (modelName, model)) ->
|
||||
let model =
|
||||
JsonModel.ToInternal defaultDeck standardTemplates clozeTemplates decks fields model
|
||||
|
||||
modelName, model
|
||||
)
|
||||
|> Map.ofSeq
|
||||
|
||||
let notes =
|
||||
collection.Decks
|
||||
|> Seq.map (fun (KeyValue (deckName, deck)) ->
|
||||
deck.Notes |> Seq.map (JsonNote.ToInternal decks.[deckName] models)
|
||||
)
|
||||
|> Seq.concat
|
||||
|> List.ofSeq
|
||||
|
||||
let collection =
|
||||
{
|
||||
CreationDate = collection.Metadata.CreationDate
|
||||
Configuration =
|
||||
{
|
||||
NewSpread = collection.Metadata.NewSpread
|
||||
CollapseTime = collection.Metadata.CollapseTimeSeconds |> Option.defaultValue 1200
|
||||
TimeLimit =
|
||||
collection.Metadata.TimeLimitSeconds
|
||||
|> Option.defaultValue 0
|
||||
|> float<int>
|
||||
|> TimeSpan.FromSeconds
|
||||
EstimateTimes = collection.Metadata.EstimateTimes |> Option.defaultValue false
|
||||
ShowDueCounts = collection.Metadata.ShowDueCounts |> Option.defaultValue true
|
||||
SortBackwards = collection.Metadata.SortBackwards |> Option.defaultValue false
|
||||
}
|
||||
DefaultModel =
|
||||
let model = models.[collection.Metadata.DefaultModelName]
|
||||
collection.Models.[collection.Metadata.DefaultModelName].ModificationTime, model
|
||||
NonDefaultModels =
|
||||
collection.Models
|
||||
|> Seq.choose (fun (KeyValue (modelKey, _model)) ->
|
||||
if modelKey <> collection.Metadata.DefaultModelName then
|
||||
let time = collection.Models.[modelKey].ModificationTime
|
||||
Some (time, models.[modelKey])
|
||||
else
|
||||
None
|
||||
)
|
||||
|> Map.ofSeq
|
||||
DefaultDeck = defaultDeck
|
||||
NonDefaultDecks =
|
||||
collection.Metadata.NonDefaultDecks
|
||||
|> Seq.map (fun (KeyValue (time, deck)) -> time, decks.[deck])
|
||||
|> Map.ofSeq
|
||||
DefaultDeckConfiguration = collection.Metadata.DefaultDeckConfiguration
|
||||
NonDefaultDeckConfiguration =
|
||||
collection.Metadata.NonDefaultDeckConfigurations
|
||||
|> Seq.map (fun kvp -> kvp.Key, kvp.Value)
|
||||
|> Map.ofSeq
|
||||
Tags = collection.Metadata.Tags
|
||||
}
|
||||
|
||||
collection, notes
|
128
AnkiStatic.Lib/SerialisedCard.fs
Normal file
128
AnkiStatic.Lib/SerialisedCard.fs
Normal file
@@ -0,0 +1,128 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.RegularExpressions
|
||||
|
||||
type SerialisedNote =
|
||||
{
|
||||
Deck : SerialisedDeck
|
||||
CreationDate : DateTimeOffset
|
||||
Model : SerialisedModel
|
||||
Tags : string list
|
||||
ValueOfSortField : string
|
||||
/// These must be in the same order as the fields of the Model.
|
||||
/// TODO: type safety to get these to line up.
|
||||
ValuesOfAdditionalFields : string list
|
||||
}
|
||||
|
||||
static member ToNote<'Model>
|
||||
(guid : uint64)
|
||||
(model : SerialisedModel -> 'Model)
|
||||
(note : SerialisedNote)
|
||||
: Note<'Model>
|
||||
=
|
||||
{
|
||||
Guid = guid
|
||||
ModelId = model note.Model
|
||||
LastModified = note.CreationDate
|
||||
UpdateSequenceNumber = -1
|
||||
Tags = note.Tags
|
||||
Fields = note.ValueOfSortField :: note.ValuesOfAdditionalFields
|
||||
SortField = Choice1Of2 note.ValueOfSortField
|
||||
Flags = 0
|
||||
Data = ""
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module SerialisedNote =
|
||||
let buildCards
|
||||
(cardCountSoFar : int)
|
||||
(deck : SerialisedDeck)
|
||||
(easeFactor : int<ease>)
|
||||
(interval : Interval)
|
||||
(note : SerialisedNote)
|
||||
: Card<SerialisedNote, SerialisedDeck> list
|
||||
=
|
||||
let primaryCard : Card<_, _> =
|
||||
{
|
||||
CreationDate = note.CreationDate
|
||||
NotesId = note
|
||||
DeckId = deck
|
||||
Interval = interval
|
||||
EaseFactor = easeFactor
|
||||
Ordinal = 0
|
||||
ModificationDate = note.CreationDate + TimeSpan.FromMilliseconds cardCountSoFar
|
||||
UpdateSequenceNumber = -1
|
||||
Type = CardType.New
|
||||
Queue = Queue.New
|
||||
Due = cardCountSoFar
|
||||
NumberOfReviews = 0
|
||||
NumberOfLapses = 0
|
||||
Left = 0
|
||||
Flags = 0
|
||||
Data = ""
|
||||
OriginalDue = 0
|
||||
}
|
||||
|
||||
let otherCards =
|
||||
let numberOfFields =
|
||||
match note.Model.Type with
|
||||
| ModelType.Cloze ->
|
||||
// Awful heuristic.
|
||||
// The first match is the primary field, which has already been counted,
|
||||
// hence the -1.
|
||||
Regex.Matches(note.ValueOfSortField, @"\{\{c\d+::(.+?)\}\}").Count - 1
|
||||
| ModelType.Standard -> note.Model.AdditionalFields.Length
|
||||
|
||||
[ 0 .. numberOfFields - 1 ]
|
||||
|> List.map (fun i ->
|
||||
{
|
||||
CreationDate = note.CreationDate
|
||||
NotesId = note
|
||||
DeckId = deck
|
||||
Interval = interval
|
||||
EaseFactor = easeFactor
|
||||
Ordinal = i + 1
|
||||
ModificationDate = note.CreationDate + TimeSpan.FromMilliseconds (float (cardCountSoFar + i + 1))
|
||||
UpdateSequenceNumber = -1
|
||||
Type = CardType.New
|
||||
Queue = Queue.New
|
||||
Due = cardCountSoFar + i + 1
|
||||
NumberOfReviews = 0
|
||||
NumberOfLapses = 0
|
||||
Left = 0
|
||||
Flags = 0
|
||||
Data = ""
|
||||
OriginalDue = 0
|
||||
}
|
||||
)
|
||||
|
||||
primaryCard :: otherCards
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Card =
|
||||
let translate<'note, 'deck>
|
||||
(noteLookup : SerialisedNote -> 'note)
|
||||
(deckLookup : SerialisedDeck -> 'deck)
|
||||
(serialised : Card<SerialisedNote, SerialisedDeck>)
|
||||
: Card<'note, 'deck>
|
||||
=
|
||||
{
|
||||
CreationDate = serialised.CreationDate
|
||||
NotesId = noteLookup serialised.NotesId
|
||||
DeckId = deckLookup serialised.DeckId
|
||||
Ordinal = serialised.Ordinal
|
||||
ModificationDate = serialised.ModificationDate
|
||||
UpdateSequenceNumber = serialised.UpdateSequenceNumber
|
||||
Type = serialised.Type
|
||||
Queue = serialised.Queue
|
||||
Due = serialised.Due
|
||||
Interval = serialised.Interval
|
||||
EaseFactor = serialised.EaseFactor
|
||||
NumberOfReviews = serialised.NumberOfReviews
|
||||
NumberOfLapses = serialised.NumberOfLapses
|
||||
Left = serialised.Left
|
||||
Flags = serialised.Flags
|
||||
Data = serialised.Data
|
||||
OriginalDue = serialised.OriginalDue
|
||||
}
|
117
AnkiStatic.Lib/SerialisedCollection.fs
Normal file
117
AnkiStatic.Lib/SerialisedCollection.fs
Normal file
@@ -0,0 +1,117 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
|
||||
type SerialisedCollection =
|
||||
{
|
||||
CreationDate : DateTimeOffset
|
||||
Configuration : SerialisedCollectionConfiguration
|
||||
DefaultModel : DateTimeOffset * SerialisedModel
|
||||
NonDefaultModels : Map<DateTimeOffset, SerialisedModel>
|
||||
DefaultDeck : SerialisedDeck
|
||||
NonDefaultDecks : Map<DateTimeOffset, SerialisedDeck>
|
||||
DefaultDeckConfiguration : SerialisedDeckConfiguration
|
||||
NonDefaultDeckConfiguration : Map<DateTimeOffset, SerialisedDeckConfiguration>
|
||||
Tags : string
|
||||
}
|
||||
|
||||
type CollectionForSql =
|
||||
{
|
||||
Decks : Map<DateTimeOffset, Deck>
|
||||
DecksInverse : SerialisedDeck -> DateTimeOffset
|
||||
Models : Map<DateTimeOffset, ModelConfiguration<DateTimeOffset>>
|
||||
ModelsInverse : SerialisedModel -> DateTimeOffset
|
||||
Collection : Collection<DateTimeOffset, DateTimeOffset>
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module SerialisedCollection =
|
||||
|
||||
let toSqlite (collection : SerialisedCollection) : CollectionForSql =
|
||||
let decks, deckLookup =
|
||||
let dict = Dictionary ()
|
||||
|
||||
let decks =
|
||||
collection.NonDefaultDecks
|
||||
|> Map.add (DateTimeOffset.FromUnixTimeMilliseconds 1) collection.DefaultDeck
|
||||
|> Map.map (fun keyTimestamp deck ->
|
||||
let converted = SerialisedDeck.ToDeck deck
|
||||
dict.Add (deck, (keyTimestamp, converted))
|
||||
converted
|
||||
)
|
||||
|
||||
let deckLookup (d : SerialisedDeck) : DateTimeOffset * Deck =
|
||||
// This could look up on reference equality rather than structural equality, for speed
|
||||
match dict.TryGetValue d with
|
||||
| true, v -> v
|
||||
| false, _ ->
|
||||
failwith
|
||||
$"A model declared that it was attached to a deck, but that deck was not declared in the deck list: %+A{d}"
|
||||
|
||||
decks, deckLookup
|
||||
|
||||
let models, modelLookup, _correctedCurrentDate =
|
||||
let dict = Dictionary ()
|
||||
|
||||
let defaultModelDate =
|
||||
let mutable currentDate =
|
||||
fst collection.DefaultModel + TimeSpan.FromMilliseconds 1.0
|
||||
|
||||
while collection.NonDefaultModels.ContainsKey currentDate do
|
||||
currentDate <- currentDate + TimeSpan.FromMilliseconds 1.0
|
||||
|
||||
currentDate
|
||||
|
||||
let models =
|
||||
collection.NonDefaultModels
|
||||
|> Map.add defaultModelDate (snd collection.DefaultModel)
|
||||
|> Map.map (fun modelTimestamp v ->
|
||||
let defaultDeckTimestamp, _deck = deckLookup v.DefaultDeck
|
||||
dict.Add (v, modelTimestamp)
|
||||
SerialisedModel.ToModel v defaultDeckTimestamp
|
||||
)
|
||||
|
||||
let modelLookup (m : SerialisedModel) : DateTimeOffset =
|
||||
match dict.TryGetValue m with
|
||||
| true, v -> v
|
||||
| false, _ ->
|
||||
failwith
|
||||
$"A note declared that it satisfied a model, but that model was not declared in the model list:\n\nDesired: %+A{m}\n\nAvailable: %+A{dict}"
|
||||
|
||||
models, modelLookup, defaultModelDate
|
||||
|
||||
let defaultDeck, _ = deckLookup collection.DefaultDeck
|
||||
|
||||
let deckConfigurations =
|
||||
collection.NonDefaultDeckConfiguration
|
||||
|> Map.add (DateTimeOffset.FromUnixTimeMilliseconds 1) collection.DefaultDeckConfiguration
|
||||
|> Map.map (fun _ -> SerialisedDeckConfiguration.ToDeckConfiguration)
|
||||
|
||||
{
|
||||
Decks = decks
|
||||
DecksInverse = fun deck -> fst (deckLookup deck)
|
||||
Models = models
|
||||
ModelsInverse = modelLookup
|
||||
Collection =
|
||||
{
|
||||
CreationDate = collection.CreationDate
|
||||
LastModified = collection.CreationDate
|
||||
LastSchemaModification = collection.CreationDate
|
||||
Version = 11
|
||||
Dirty = 0
|
||||
UpdateSequenceNumber = -1
|
||||
LastSync = DateTimeOffset.FromUnixTimeSeconds 0
|
||||
Configuration =
|
||||
collection.Configuration
|
||||
|> SerialisedCollectionConfiguration.ToCollectionConfiguration
|
||||
(Some defaultDeck)
|
||||
// TODO: work out what it means for a deck to be a "descendant" of another
|
||||
[ defaultDeck ]
|
||||
(fst collection.DefaultModel)
|
||||
Models = models
|
||||
Decks = decks
|
||||
DeckConfigurations = deckConfigurations
|
||||
Tags = collection.Tags
|
||||
}
|
||||
}
|
218
AnkiStatic.Lib/SerialisedDomain.fs
Normal file
218
AnkiStatic.Lib/SerialisedDomain.fs
Normal file
@@ -0,0 +1,218 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
|
||||
type SerialisedDeck =
|
||||
{
|
||||
Name : string
|
||||
ExtendedReviewLimit : int option
|
||||
ExtendedNewCardLimit : int option
|
||||
Collapsed : bool
|
||||
BrowserCollapsed : bool
|
||||
Description : string
|
||||
}
|
||||
|
||||
static member ToDeck (deck : SerialisedDeck) : Deck =
|
||||
{
|
||||
Name = deck.Name
|
||||
ExtendedReviewLimit = deck.ExtendedReviewLimit
|
||||
ExtendedNewCardLimit = deck.ExtendedNewCardLimit
|
||||
UpdateSequenceNumber = -1
|
||||
Collapsed = deck.Collapsed
|
||||
BrowserCollapsed = deck.BrowserCollapsed
|
||||
Description = deck.Description
|
||||
LastModified = DateTimeOffset.FromUnixTimeSeconds 0
|
||||
}
|
||||
|
||||
type SerialisedModelField =
|
||||
{
|
||||
/// E.g. "Arial"
|
||||
Font : string
|
||||
Name : string
|
||||
/// Whether text should display right-to-left
|
||||
RightToLeft : bool
|
||||
FontSize : int
|
||||
Sticky : bool
|
||||
}
|
||||
|
||||
static member ToModelField (counter : int) (field : SerialisedModelField) : ModelField =
|
||||
{
|
||||
Font = field.Font
|
||||
FontSize = field.FontSize
|
||||
Media = []
|
||||
Name = field.Name
|
||||
Ord = counter
|
||||
RightToLeft = field.RightToLeft
|
||||
Sticky = field.Sticky
|
||||
}
|
||||
|
||||
type SerialisedCardTemplate =
|
||||
{
|
||||
AnswerFormat : string
|
||||
BrowserAnswerFormat : string
|
||||
BrowserQuestionFormat : string
|
||||
Name : string
|
||||
QuestionFormat : string
|
||||
}
|
||||
|
||||
static member ToCardTemplate<'Deck>
|
||||
(deck : 'Deck option)
|
||||
(counter : int)
|
||||
(template : SerialisedCardTemplate)
|
||||
: CardTemplate<'Deck>
|
||||
=
|
||||
{
|
||||
AnswerFormat = template.AnswerFormat
|
||||
BrowserAnswerFormat = template.BrowserAnswerFormat
|
||||
BrowserQuestionFormat = template.BrowserQuestionFormat
|
||||
Name = template.Name
|
||||
QuestionFormat = template.QuestionFormat
|
||||
DeckOverride = deck
|
||||
Ord = counter
|
||||
}
|
||||
|
||||
type SerialisedModel =
|
||||
{
|
||||
Css : string
|
||||
/// Any extra fields which are not the sort field
|
||||
AdditionalFields : SerialisedModelField list
|
||||
/// String which is added to terminate LaTeX expressions
|
||||
LatexPost : string
|
||||
LatexPre : string
|
||||
Name : string
|
||||
/// Which field the browser uses to sort by
|
||||
SortField : SerialisedModelField
|
||||
/// An invariant which is not maintained at construction:
|
||||
/// if the Type is Cloze, then this templates list must have length exactly 1.
|
||||
Templates : SerialisedCardTemplate list
|
||||
Type : ModelType
|
||||
DefaultDeck : SerialisedDeck
|
||||
}
|
||||
|
||||
static member ToModel<'Deck> (s : SerialisedModel) (deck : 'Deck) : ModelConfiguration<'Deck> =
|
||||
match s.Type, s.Templates with
|
||||
| ModelType.Cloze, [] -> failwith $"A cloze model must have exactly one template, but got 0: %+A{s}"
|
||||
| ModelType.Cloze, _ :: _ :: _ ->
|
||||
failwith $"A cloze model must have exactly one template, but got at least 2: %+A{s}"
|
||||
| _, _ -> ()
|
||||
|
||||
{
|
||||
Css = s.Css
|
||||
DefaultDeckId = deck
|
||||
Fields =
|
||||
(s.SortField :: s.AdditionalFields)
|
||||
|> List.mapi SerialisedModelField.ToModelField
|
||||
LatexPost = s.LatexPost
|
||||
LatexPre = s.LatexPre
|
||||
LastModification = DateTimeOffset.FromUnixTimeSeconds 0
|
||||
Name = s.Name
|
||||
SortField = 0
|
||||
Tags = []
|
||||
Templates = s.Templates |> List.mapi (SerialisedCardTemplate.ToCardTemplate None)
|
||||
Type = s.Type
|
||||
UpdateSequenceNumber = -1
|
||||
Version = []
|
||||
}
|
||||
|
||||
type SerialisedNewCardConfiguration =
|
||||
{
|
||||
Delays : int list
|
||||
InitialEase : int<ease>
|
||||
Intervals : IntervalConfiguration
|
||||
Order : NewCardOrder
|
||||
MaxNewPerDay : int
|
||||
}
|
||||
|
||||
static member ToNewCardConfiguration (conf : SerialisedNewCardConfiguration) : NewCardConfiguration =
|
||||
{
|
||||
Bury = true
|
||||
Delays = conf.Delays
|
||||
InitialEase = conf.InitialEase
|
||||
Intervals = conf.Intervals
|
||||
Order = conf.Order
|
||||
MaxNewPerDay = conf.MaxNewPerDay
|
||||
Separate = true
|
||||
}
|
||||
|
||||
type SerialisedReviewConfiguration =
|
||||
{
|
||||
EasinessPerEasyReview : float
|
||||
Fuzz : float
|
||||
IntervalFactor : int
|
||||
MaxInterval : TimeSpan
|
||||
PerDay : int
|
||||
}
|
||||
|
||||
static member ToReviewConfiguration (conf : SerialisedReviewConfiguration) : ReviewConfiguration =
|
||||
{
|
||||
Bury = true
|
||||
EasinessPerEasyReview = conf.EasinessPerEasyReview
|
||||
Fuzz = conf.Fuzz
|
||||
IntervalFactor = conf.IntervalFactor
|
||||
MaxInterval = conf.MaxInterval
|
||||
MinSpace = 1
|
||||
PerDay = conf.PerDay
|
||||
}
|
||||
|
||||
|
||||
type SerialisedDeckConfiguration =
|
||||
{
|
||||
AutoPlay : bool
|
||||
Lapse : LapseConfiguration
|
||||
Name : string
|
||||
New : SerialisedNewCardConfiguration
|
||||
ReplayQuestionAudioWithAnswer : bool
|
||||
Review : SerialisedReviewConfiguration
|
||||
ShowTimer : bool
|
||||
MaxTimerTimeout : TimeSpan
|
||||
}
|
||||
|
||||
static member ToDeckConfiguration (conf : SerialisedDeckConfiguration) : DeckConfiguration =
|
||||
{
|
||||
AutoPlay = conf.AutoPlay
|
||||
Lapse = conf.Lapse
|
||||
MaxTaken = conf.MaxTimerTimeout
|
||||
LastModified = DateTimeOffset.FromUnixTimeSeconds 0
|
||||
Name = conf.Name
|
||||
New = conf.New |> SerialisedNewCardConfiguration.ToNewCardConfiguration
|
||||
ReplayQuestionAudioWithAnswer = conf.ReplayQuestionAudioWithAnswer
|
||||
Review = conf.Review |> SerialisedReviewConfiguration.ToReviewConfiguration
|
||||
ShowTimer = conf.ShowTimer
|
||||
UpdateSequenceNumber = -1
|
||||
}
|
||||
|
||||
type SerialisedCollectionConfiguration =
|
||||
{
|
||||
NewSpread : NewCardDistribution
|
||||
CollapseTime : int
|
||||
TimeLimit : TimeSpan
|
||||
EstimateTimes : bool
|
||||
ShowDueCounts : bool
|
||||
SortBackwards : bool
|
||||
}
|
||||
|
||||
static member ToCollectionConfiguration
|
||||
(currentDeck : 'Deck option)
|
||||
(activeDecks : 'Deck list)
|
||||
(currentModel : 'Model)
|
||||
(conf : SerialisedCollectionConfiguration)
|
||||
: CollectionConfiguration<'Model, 'Deck>
|
||||
=
|
||||
{
|
||||
CurrentDeck = currentDeck
|
||||
ActiveDecks = activeDecks
|
||||
NewSpread = conf.NewSpread
|
||||
CollapseTime = conf.CollapseTime
|
||||
TimeLimit = conf.TimeLimit
|
||||
EstimateTimes = conf.EstimateTimes
|
||||
ShowDueCounts = conf.ShowDueCounts
|
||||
CurrentModel = currentModel
|
||||
NextPosition =
|
||||
// TODO: get this to pick up the incrementing counter
|
||||
4
|
||||
SortType =
|
||||
// TODO: generalise this
|
||||
"noteFld"
|
||||
SortBackwards = conf.SortBackwards
|
||||
AddToCurrent = true
|
||||
}
|
415
AnkiStatic.Lib/Sqlite.fs
Normal file
415
AnkiStatic.Lib/Sqlite.fs
Normal file
@@ -0,0 +1,415 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open Microsoft.Data.Sqlite
|
||||
open System.Threading.Tasks
|
||||
|
||||
type EmptyPackage = private | EmptyPackage of FileInfo
|
||||
|
||||
type Package =
|
||||
private
|
||||
| Package of FileInfo
|
||||
|
||||
member this.GetFileInfo () =
|
||||
match this with
|
||||
| Package p -> p
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Sqlite =
|
||||
let private executeCreateStatement (conn : SqliteConnection) (statement : string) =
|
||||
task {
|
||||
use cmd = conn.CreateCommand ()
|
||||
cmd.CommandText <- statement
|
||||
let! result = cmd.ExecuteNonQueryAsync ()
|
||||
|
||||
if result <> 0 then
|
||||
return failwith "unexpectedly created a row in cards creation"
|
||||
}
|
||||
|
||||
let createEmptyPackage (path : FileInfo) : Task<EmptyPackage> =
|
||||
if path.FullName.Contains ';' then
|
||||
failwith "Path contained connection string metacharacter ';', so aborting."
|
||||
|
||||
task {
|
||||
// Connect to the SQLite database; create if not exists
|
||||
let connectionString = $"Data Source=%s{path.FullName};"
|
||||
use connection = new SqliteConnection (connectionString)
|
||||
connection.Open ()
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE TABLE cards (
|
||||
id integer primary key,
|
||||
nid integer not null,
|
||||
did integer not null,
|
||||
ord integer not null,
|
||||
mod integer not null,
|
||||
usn integer not null,
|
||||
type integer not null,
|
||||
queue integer not null,
|
||||
due integer not null,
|
||||
ivl integer not null,
|
||||
factor integer not null,
|
||||
reps integer not null,
|
||||
lapses integer not null,
|
||||
left integer not null,
|
||||
odue integer not null,
|
||||
odid integer not null,
|
||||
flags integer not null,
|
||||
data text not null
|
||||
)"""
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE TABLE col (
|
||||
id integer primary key,
|
||||
crt integer not null,
|
||||
mod integer not null,
|
||||
scm integer not null,
|
||||
ver integer not null,
|
||||
dty integer not null,
|
||||
usn integer not null,
|
||||
ls integer not null,
|
||||
conf text not null,
|
||||
models text not null,
|
||||
decks text not null,
|
||||
dconf text not null,
|
||||
tags text not null
|
||||
)"""
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE TABLE graves (
|
||||
usn integer not null,
|
||||
oid integer not null,
|
||||
type integer not null
|
||||
)"""
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE TABLE notes (
|
||||
id integer primary key,
|
||||
guid text not null,
|
||||
mid integer not null,
|
||||
mod integer not null,
|
||||
usn integer not null,
|
||||
tags text not null,
|
||||
flds text not null,
|
||||
sfld integer not null,
|
||||
csum integer not null,
|
||||
flags integer not null,
|
||||
data text not null
|
||||
)"""
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE TABLE revlog (
|
||||
id integer primary key,
|
||||
cid integer not null,
|
||||
usn integer not null,
|
||||
ease integer not null,
|
||||
ivl integer not null,
|
||||
lastIvl integer not null,
|
||||
factor integer not null,
|
||||
time integer not null,
|
||||
type integer not null
|
||||
)"""
|
||||
|
||||
do!
|
||||
executeCreateStatement
|
||||
connection
|
||||
"""
|
||||
CREATE INDEX ix_cards_nid on cards (nid);
|
||||
CREATE INDEX ix_cards_sched on cards (did, queue, due);
|
||||
CREATE INDEX ix_cards_usn on cards (usn);
|
||||
CREATE INDEX ix_notes_csum on notes (csum);
|
||||
CREATE INDEX ix_notes_usn on notes (usn);
|
||||
CREATE INDEX ix_revlog_cid on revlog (cid);
|
||||
CREATE INDEX ix_revlog_usn on revlog (usn)
|
||||
"""
|
||||
|
||||
return EmptyPackage path
|
||||
}
|
||||
|
||||
let createDecks (EmptyPackage sqliteDb) (collection : Collection<DateTimeOffset, DateTimeOffset>) : Task<Package> =
|
||||
task {
|
||||
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
|
||||
use connection = new SqliteConnection (connectionString)
|
||||
connection.Open ()
|
||||
|
||||
let cmd = connection.CreateCommand ()
|
||||
cmd.Connection <- connection
|
||||
|
||||
cmd.CommandText <-
|
||||
"""
|
||||
INSERT INTO col
|
||||
(crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
|
||||
VALUES ($crt, $mod, $scm, $ver, $dty, $usn, $ls, $conf, $models, $decks, $dconf, $tags)
|
||||
"""
|
||||
|
||||
cmd.Parameters.AddWithValue ("crt", collection.CreationDate.ToUnixTimeSeconds ())
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("mod", collection.LastModified.ToUnixTimeSeconds ())
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("scm", collection.LastSchemaModification.ToUnixTimeSeconds ())
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("ver", collection.Version) |> ignore
|
||||
cmd.Parameters.AddWithValue ("dty", collection.Dirty) |> ignore
|
||||
cmd.Parameters.AddWithValue ("usn", collection.UpdateSequenceNumber) |> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("ls", collection.LastSync.ToUnixTimeSeconds ())
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("conf", collection.Configuration |> CollectionConfiguration.toJsonString)
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("models", Collection.getJsonModelString collection)
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("decks", Collection.getJsonDeckString collection)
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("dconf", Collection.getDeckConfigurationString collection)
|
||||
|> ignore
|
||||
|
||||
cmd.Parameters.AddWithValue ("tags", collection.Tags) |> ignore
|
||||
|
||||
let! rows = cmd.ExecuteNonQueryAsync ()
|
||||
|
||||
if rows <> 1 then
|
||||
return failwith $"Failed to insert collection row (got: %i{rows})"
|
||||
else
|
||||
|
||||
return Package sqliteDb
|
||||
}
|
||||
|
||||
/// Returns the note ID for each input note, in order.
|
||||
let createNotes (Package sqliteDb) (notes : Note<DateTimeOffset> list) : int64 IReadOnlyList Task =
|
||||
// The Anki model is *absolutely* insane and uses time of creation of a note
|
||||
// as a unique key.
|
||||
// Work around this madness.
|
||||
|
||||
let notes =
|
||||
let seenSoFar = HashSet ()
|
||||
let mutable maxSoFar = DateTimeOffset.MinValue
|
||||
|
||||
notes
|
||||
|> List.map (fun node ->
|
||||
maxSoFar <- max maxSoFar node.LastModified
|
||||
|
||||
if not (seenSoFar.Add node.LastModified) then
|
||||
maxSoFar <- maxSoFar + TimeSpan.FromMilliseconds 1.0
|
||||
|
||||
if not (seenSoFar.Add maxSoFar) then
|
||||
failwith "logic has failed me"
|
||||
|
||||
{ node with
|
||||
LastModified = maxSoFar
|
||||
}
|
||||
else
|
||||
node
|
||||
)
|
||||
|
||||
task {
|
||||
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
|
||||
use connection = new SqliteConnection (connectionString)
|
||||
connection.Open ()
|
||||
|
||||
let cmd = connection.CreateCommand ()
|
||||
|
||||
cmd.CommandText <-
|
||||
"""
|
||||
INSERT INTO notes
|
||||
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
|
||||
VALUES ($id, $guid, $mid, $mod, $usn, $tags, $flds, $sfld, $csum, $flags, $data)
|
||||
"""
|
||||
|
||||
cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("guid", SqliteType.Text) |> ignore
|
||||
cmd.Parameters.Add ("mid", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("tags", SqliteType.Text) |> ignore
|
||||
cmd.Parameters.Add ("flds", SqliteType.Text) |> ignore
|
||||
cmd.Parameters.Add ("sfld", SqliteType.Text) |> ignore
|
||||
cmd.Parameters.Add ("csum", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("data", SqliteType.Text) |> ignore
|
||||
do! cmd.PrepareAsync ()
|
||||
|
||||
let result = ResizeArray ()
|
||||
|
||||
for note in notes do
|
||||
cmd.Parameters.["id"].Value <- note.LastModified.ToUnixTimeMilliseconds ()
|
||||
cmd.Parameters.["guid"].Value <- note.Guid |> Base91.toString
|
||||
cmd.Parameters.["mid"].Value <- note.ModelId.ToUnixTimeMilliseconds ()
|
||||
cmd.Parameters.["mod"].Value <- note.LastModified.ToUnixTimeSeconds ()
|
||||
cmd.Parameters.["usn"].Value <- note.UpdateSequenceNumber
|
||||
|
||||
cmd.Parameters.["tags"].Value <-
|
||||
match note.Tags with
|
||||
| [] -> ""
|
||||
| tags -> String.concat " " tags |> sprintf " %s "
|
||||
|
||||
cmd.Parameters.["flds"].Value <- note.Fields |> String.concat "\u001f"
|
||||
|
||||
cmd.Parameters.["sfld"].Value <-
|
||||
match note.SortField with
|
||||
| Choice1Of2 s -> s
|
||||
| Choice2Of2 i -> i.ToString ()
|
||||
|
||||
cmd.Parameters.["csum"].Value <- note.Checksum
|
||||
cmd.Parameters.["flags"].Value <- note.Flags
|
||||
cmd.Parameters.["data"].Value <- note.Data
|
||||
|
||||
let! count = cmd.ExecuteNonQueryAsync ()
|
||||
|
||||
if count <> 1 then
|
||||
failwithf "failed to insert note, got count: %i" count
|
||||
|
||||
let id = note.LastModified.ToUnixTimeMilliseconds ()
|
||||
result.Add id
|
||||
|
||||
return result :> IReadOnlyList<_>
|
||||
}
|
||||
|
||||
let createCards (Package sqliteDb) (cards : Card<int64, DateTimeOffset> list) =
|
||||
task {
|
||||
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
|
||||
use connection = new SqliteConnection (connectionString)
|
||||
connection.Open ()
|
||||
|
||||
let cmd = connection.CreateCommand ()
|
||||
|
||||
cmd.CommandText <-
|
||||
"""
|
||||
INSERT INTO cards
|
||||
(id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
|
||||
VALUES (@id, @nid, @did, @ord, @mod, @usn, @type, @queue, @due, @ivl, @factor, @reps, @lapses, @left, @odue, @odid, @flags, @data)
|
||||
"""
|
||||
|
||||
cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("nid", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("did", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("ord", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("type", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("queue", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("due", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("ivl", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("factor", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("reps", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("lapses", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("left", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("odue", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("odid", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore
|
||||
cmd.Parameters.Add ("data", SqliteType.Text) |> ignore
|
||||
do! cmd.PrepareAsync ()
|
||||
|
||||
for card in cards do
|
||||
cmd.Parameters.["id"].Value <- card.ModificationDate.ToUnixTimeMilliseconds ()
|
||||
cmd.Parameters.["nid"].Value <- card.NotesId
|
||||
cmd.Parameters.["did"].Value <- card.DeckId.ToUnixTimeMilliseconds ()
|
||||
cmd.Parameters.["ord"].Value <- card.Ordinal
|
||||
cmd.Parameters.["mod"].Value <- card.ModificationDate.ToUnixTimeSeconds ()
|
||||
cmd.Parameters.["usn"].Value <- card.UpdateSequenceNumber
|
||||
cmd.Parameters.["type"].Value <- card.Type.ToInteger ()
|
||||
cmd.Parameters.["queue"].Value <- card.Queue.ToInteger ()
|
||||
cmd.Parameters.["due"].Value <- card.Due
|
||||
cmd.Parameters.["ivl"].Value <- card.Interval.ToInteger ()
|
||||
cmd.Parameters.["factor"].Value <- card.EaseFactor
|
||||
cmd.Parameters.["reps"].Value <- card.NumberOfReviews
|
||||
cmd.Parameters.["lapses"].Value <- card.NumberOfLapses
|
||||
cmd.Parameters.["left"].Value <- card.Left
|
||||
cmd.Parameters.["odue"].Value <- card.OriginalDue
|
||||
cmd.Parameters.["odid"].Value <- 0
|
||||
cmd.Parameters.["flags"].Value <- card.Flags
|
||||
cmd.Parameters.["data"].Value <- card.Data
|
||||
|
||||
let! result = cmd.ExecuteNonQueryAsync ()
|
||||
|
||||
if result <> 1 then
|
||||
failwith $"Did not get exactly 1 row back from insertion: %i{result}"
|
||||
|
||||
return ()
|
||||
}
|
||||
|
||||
let writeAll
|
||||
(rng : Random)
|
||||
(collection : CollectionForSql)
|
||||
(notes : SerialisedNote list)
|
||||
(outputFile : FileInfo)
|
||||
: Task<unit>
|
||||
=
|
||||
let renderedNotes, lookupNote =
|
||||
let dict = Dictionary ()
|
||||
|
||||
let buffer = BitConverter.GetBytes (uint64 0)
|
||||
|
||||
let result =
|
||||
notes
|
||||
|> List.mapi (fun i note ->
|
||||
rng.NextBytes buffer
|
||||
let guid = BitConverter.ToUInt64 (buffer, 0)
|
||||
dict.Add (note, i)
|
||||
SerialisedNote.ToNote guid collection.ModelsInverse note
|
||||
)
|
||||
|
||||
let lookupNote (note : SerialisedNote) : int =
|
||||
match dict.TryGetValue note with
|
||||
| true, v -> v
|
||||
| false, _ ->
|
||||
failwith
|
||||
$"A card declared that it was associated with a note, but that note was not inserted.\nDesired: %+A{note}\nAvailable:\n%+A{dict}"
|
||||
|
||||
result, lookupNote
|
||||
|
||||
let tempFile = Path.GetTempFileName () |> FileInfo
|
||||
|
||||
task {
|
||||
let! package = createEmptyPackage tempFile
|
||||
|
||||
let! written = collection.Collection |> createDecks package
|
||||
|
||||
let! noteIds = createNotes written renderedNotes
|
||||
|
||||
let _, _, cards =
|
||||
((0, 0, []), notes)
|
||||
||> List.fold (fun (count, iter, cards) note ->
|
||||
let built =
|
||||
SerialisedNote.buildCards count note.Deck 1000<ease> Interval.Unset note
|
||||
|> List.map (Card.translate (fun note -> noteIds.[lookupNote note]) collection.DecksInverse)
|
||||
|
||||
built.Length + count, iter + 1, built @ cards
|
||||
)
|
||||
|
||||
do! createCards written cards
|
||||
|
||||
use outputStream = outputFile.OpenWrite ()
|
||||
use archive = new ZipArchive (outputStream, ZipArchiveMode.Create, true)
|
||||
|
||||
let entry = archive.CreateEntry "collection.anki2"
|
||||
use entryStream = entry.Open ()
|
||||
use contents = tempFile.OpenRead ()
|
||||
do! contents.CopyToAsync entryStream
|
||||
|
||||
return ()
|
||||
}
|
555
AnkiStatic.Lib/anki.schema.json
Normal file
555
AnkiStatic.Lib/anki.schema.json
Normal file
@@ -0,0 +1,555 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"title": "Collection",
|
||||
"description": "An entire Anki collection.",
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"definitions": {
|
||||
"reviewConfiguration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"easinessPerEasyReview",
|
||||
"fuzz",
|
||||
"intervalFactor",
|
||||
"maxIntervalDays",
|
||||
"perDay"
|
||||
],
|
||||
"properties": {
|
||||
"easinessPerEasyReview": {
|
||||
"type": "number",
|
||||
"description": "An extra multiplier applied to a card's Ease when you rate it Easy"
|
||||
},
|
||||
"fuzz": {
|
||||
"type": "number",
|
||||
"description": "Fudge factor to prevent cards which were created and reviewed together from all appearing together. Semantics unknown; observed to be 0.05 in the wild"
|
||||
},
|
||||
"intervalFactor": {
|
||||
"type": "integer",
|
||||
"description": "Multiplication factor applied to the intervals Anki generates; unknown semantics, observed to be 1 in the wild"
|
||||
},
|
||||
"maxIntervalDays": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of days that can pass between reviews",
|
||||
"minimum": 0
|
||||
},
|
||||
"perDay": {
|
||||
"type": "integer",
|
||||
"description": "Number of cards to review per day",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"lapse": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"delays",
|
||||
"leechAction",
|
||||
"leechFails",
|
||||
"minInterval",
|
||||
"multiplier"
|
||||
],
|
||||
"properties": {
|
||||
"delays": {
|
||||
"type": "array",
|
||||
"description": "Successive delays in days between the learning steps of the cards",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"leechAction": {
|
||||
"enum": [
|
||||
"suspend",
|
||||
"mark"
|
||||
],
|
||||
"description": "What to do when a card in this deck becomes a leech"
|
||||
},
|
||||
"leechFails": {
|
||||
"type": "integer",
|
||||
"description": "Number of times a review of a card must fail before the card is marked as a leech",
|
||||
"minimum": 0
|
||||
},
|
||||
"minInterval": {
|
||||
"type": "integer",
|
||||
"description": "Lower limit of the new interval after a card is marked leech, in days",
|
||||
"minimum": 1
|
||||
},
|
||||
"multiplier": {
|
||||
"type": "number",
|
||||
"description": "The multiplier applied to a review interval when answering Again.",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"newCardConfiguration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"delays",
|
||||
"initialEase",
|
||||
"intervals",
|
||||
"order",
|
||||
"maxNewPerDay"
|
||||
],
|
||||
"properties": {
|
||||
"delays": {
|
||||
"type": "array",
|
||||
"description": "The list of successive delays between learning steps of new cards, in minutes",
|
||||
"items": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
},
|
||||
"initialEase": {
|
||||
"type": "integer",
|
||||
"description": "100x the multiplier for how much the Good button will delay the next review, so 2500 delays the next review 2.5x on a Good review",
|
||||
"minimum": 0
|
||||
},
|
||||
"intervals": {
|
||||
"type": "object",
|
||||
"description": "List of delays when leaving learning mode after pressing the various buttons",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"good",
|
||||
"easy",
|
||||
"unused"
|
||||
],
|
||||
"properties": {
|
||||
"good": {
|
||||
"type": "integer",
|
||||
"description": "The delay in days after pressing the Good button",
|
||||
"minimum": 0
|
||||
},
|
||||
"easy": {
|
||||
"type": "integer",
|
||||
"description": "The delay in days after pressing the Easy button",
|
||||
"minimum": 0
|
||||
},
|
||||
"unused": {
|
||||
"type": "integer",
|
||||
"description": "An unused delay, probably set this to 7",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"order": {
|
||||
"enum": [
|
||||
"random",
|
||||
"due"
|
||||
],
|
||||
"description": "How to display new cards - by order of due date, or at random"
|
||||
},
|
||||
"maxNewPerDay": {
|
||||
"description": "How many new cards can be shown per day",
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"field": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "A field of a note, holding a single piece of data; a card may ask you to recall a field, for example",
|
||||
"required": [
|
||||
"displayName"
|
||||
],
|
||||
"properties": {
|
||||
"displayName": {
|
||||
"type": "string",
|
||||
"description": "The name used inside templates to refer to this field"
|
||||
},
|
||||
"rightToLeft": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"sticky": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"fontSize": {
|
||||
"type": "integer",
|
||||
"minimum": 0
|
||||
},
|
||||
"font": {
|
||||
"type": "string",
|
||||
"description": "e.g. Arial"
|
||||
}
|
||||
}
|
||||
},
|
||||
"deckConfiguration": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Configuration of a deck, without any mention of its notes",
|
||||
"required": [
|
||||
"autoPlay",
|
||||
"lapse",
|
||||
"name",
|
||||
"new",
|
||||
"replayQuestionAudioWithAnswer",
|
||||
"review",
|
||||
"showTimer",
|
||||
"maxTimerTimeoutSeconds"
|
||||
],
|
||||
"properties": {
|
||||
"autoPlay": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to play audio immediately on showing the question"
|
||||
},
|
||||
"lapse": {
|
||||
"description": "What to do with lapsed cards",
|
||||
"$ref": "#/definitions/lapse"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Name of the deck configuration, which as far as I can tell is unused"
|
||||
},
|
||||
"new": {
|
||||
"description": "How to show new cards from the deck",
|
||||
"$ref": "#/definitions/newCardConfiguration"
|
||||
},
|
||||
"replayQuestionAudioWithAnswer": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to replay question audio when the answer is displayed"
|
||||
},
|
||||
"review": {
|
||||
"description": "Configuration governing how card metadata changes with each review",
|
||||
"$ref": "#/definitions/reviewConfiguration"
|
||||
},
|
||||
"showTimer": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to show a timer while cards are open"
|
||||
},
|
||||
"maxTimerTimeoutSeconds": {
|
||||
"type": "integer",
|
||||
"description": "The time in seconds after which to stop the timer",
|
||||
"minimum": 0
|
||||
}
|
||||
}
|
||||
},
|
||||
"standardTemplate": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Each non-cloze note gets turned into `n` cards by applying `n` templates to the note. The template determines which fields get shown where on the card.",
|
||||
"required": [
|
||||
"answerFormat",
|
||||
"questionFormat",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"answerFormat": {
|
||||
"type": "string",
|
||||
"description": "How the answer of this card gets displayed. You can refer to fields of the note with {{FieldName}}.",
|
||||
"example": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}"
|
||||
},
|
||||
"questionFormat": {
|
||||
"type": "string",
|
||||
"description": "How the question of this card gets displayed. You can refer to fields of the note with {{FieldName}}.",
|
||||
"example": "{{Front}}"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "A display name for this card-template. It'll probably be confusing if you reuse this name across multiple templates in the same card, but I think it's allowed."
|
||||
},
|
||||
"browserAnswerFormat": {
|
||||
"type": "string",
|
||||
"description": "Nobody seems to know what this is, but it's used for displaying the answer in the card browser"
|
||||
},
|
||||
"browserQuestionFormat": {
|
||||
"type": "string",
|
||||
"description": "Nobody seems to know what this is, but it's used for displaying the question in the card browser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clozeTemplate": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Each cloze note gets turned into `n` cards by removing one of the `n cloze deletions in that card.",
|
||||
"required": [
|
||||
"answerFormat",
|
||||
"questionFormat",
|
||||
"name"
|
||||
],
|
||||
"properties": {
|
||||
"answerFormat": {
|
||||
"type": "string",
|
||||
"description": "How the answer of this card gets displayed. You can refer to the text generated by deleting a cloze from FieldName with {{cloze:FieldName}}, and other fields with {{FieldName}}.",
|
||||
"example": "{{cloze:Text}}<br>\n{{Extra}}"
|
||||
},
|
||||
"questionFormat": {
|
||||
"type": "string",
|
||||
"description": "How the question of this card gets displayed. You can refer to the text generated by deleting a cloze from FieldName with {{cloze:FieldName}}, and other fields with {{FieldName}}.",
|
||||
"example": "{{cloze:Text}}"
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "A display name for this card-template. It'll probably be confusing if you reuse this name across multiple templates in the same card, but I think it's allowed."
|
||||
},
|
||||
"browserAnswerFormat": {
|
||||
"type": "string",
|
||||
"description": "Nobody seems to know what this is, but it's used for displaying the answer in the card browser"
|
||||
},
|
||||
"browserQuestionFormat": {
|
||||
"type": "string",
|
||||
"description": "Nobody seems to know what this is, but it's used for displaying the question in the card browser"
|
||||
}
|
||||
}
|
||||
},
|
||||
"metadata": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"description": "Metadata governing this entire collection.",
|
||||
"required": [
|
||||
"creationDate",
|
||||
"defaultDeck",
|
||||
"defaultDeckConfiguration",
|
||||
"defaultModelName"
|
||||
],
|
||||
"properties": {
|
||||
"creationDate": {
|
||||
"type": "string",
|
||||
"format": "date-time",
|
||||
"description": "Displayed creation date for this collection"
|
||||
},
|
||||
"collapseTimeSeconds": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "If there are no more cards to review now, but the next card in the 'learning' state is due in less than this number of seconds, show it now.",
|
||||
"example": 1200
|
||||
},
|
||||
"timeLimitSeconds": {
|
||||
"type": "integer",
|
||||
"description": "Time-boxing limit when reviewing cards. Whenever this number of seconds elapse, Anki tells you how many card you reviewed. Omit for \"no limit\".",
|
||||
"minimum": 1
|
||||
},
|
||||
"estimateTimes": {
|
||||
"type": "boolean",
|
||||
"description": "Show the next review time above each answer button"
|
||||
},
|
||||
"newSpread": {
|
||||
"enum": [
|
||||
"distribute",
|
||||
"last",
|
||||
"first"
|
||||
],
|
||||
"description": "How to decide which new cards to present to you"
|
||||
},
|
||||
"showDueCounts": {
|
||||
"type": "boolean",
|
||||
"description": "Show remaining card count above answer buttons"
|
||||
},
|
||||
"sortBackwards": {
|
||||
"type": "boolean",
|
||||
"description": "Whether to show cards in the browser in decreasing order, whatever that means"
|
||||
},
|
||||
"defaultDeck": {
|
||||
"type": "string",
|
||||
"description": "The default deck of this collection, whatever that means. The values must be keys of the `decks` mapping."
|
||||
},
|
||||
"defaultDeckConfiguration": {
|
||||
"description": "Configuration of the default deck",
|
||||
"$ref": "#/definitions/deckConfiguration"
|
||||
},
|
||||
"tags": {
|
||||
"type": "string",
|
||||
"description": "TODO what are the semantics of this",
|
||||
"example": "{}"
|
||||
},
|
||||
"defaultModelName": {
|
||||
"description": "The default model for new cards in this collection. The values must be keys of the `models` mapping",
|
||||
"type": "string"
|
||||
},
|
||||
"nonDefaultDecks": {
|
||||
"type": "object",
|
||||
"description": "TODO"
|
||||
},
|
||||
"nonDefaultDeckConfigurations": {
|
||||
"type": "object",
|
||||
"description": "TODO"
|
||||
}
|
||||
}
|
||||
},
|
||||
"note": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"model",
|
||||
"creationDate",
|
||||
"sortFieldValue",
|
||||
"additionalFieldValues",
|
||||
"tags"
|
||||
],
|
||||
"properties": {
|
||||
"tags": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"sortFieldValue": {
|
||||
"type": "string",
|
||||
"description": "The text that appears in the primary (sorting) field of this note"
|
||||
},
|
||||
"additionalFieldValues": {
|
||||
"type": "array",
|
||||
"description": "The text that appears in each non-primary field of this note",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"creationDate": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "The value must be a key of the `models` mapping."
|
||||
}
|
||||
}
|
||||
},
|
||||
"deck": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"description",
|
||||
"notes"
|
||||
],
|
||||
"properties": {
|
||||
"extendedReviewLimit": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "When doing an Extended Review custom study, the number of cards to be reviewed."
|
||||
},
|
||||
"extendedNewCardLimit": {
|
||||
"type": "integer",
|
||||
"minimum": 0,
|
||||
"description": "When doing an Extended New custom study, the number of new cards to be shown."
|
||||
},
|
||||
"collapsed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"browserCollapsed": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"description": {
|
||||
"type": "string"
|
||||
},
|
||||
"notes": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/note"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"model": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"required": [
|
||||
"sortField",
|
||||
"additionalFields",
|
||||
"type",
|
||||
"name",
|
||||
"templates"
|
||||
],
|
||||
"properties": {
|
||||
"modificationTime": {
|
||||
"type": "string",
|
||||
"format": "date-time"
|
||||
},
|
||||
"css": {
|
||||
"type": "string"
|
||||
},
|
||||
"latexPost": {
|
||||
"type": "string"
|
||||
},
|
||||
"latexPre": {
|
||||
"type": "string"
|
||||
},
|
||||
"sortField": {
|
||||
"type": "string",
|
||||
"description": "TODO what relation does this have with Templates?"
|
||||
},
|
||||
"additionalFields": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"templates": {
|
||||
"type": "array",
|
||||
"description": "These values must be keys of `clozeTemplates` if `type` is \"cloze\", or of `standardTemplates` if `type` is \"standard\".",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": {
|
||||
"enum": [
|
||||
"cloze",
|
||||
"standard"
|
||||
]
|
||||
},
|
||||
"name": {
|
||||
"type": "string",
|
||||
"description": "Display name"
|
||||
},
|
||||
"defaultDeck": {
|
||||
"type": "string",
|
||||
"description": "Deck into which new notes under this model are placed by default. The value must be a key of the `decks` mapping."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"metadata",
|
||||
"standardTemplates",
|
||||
"clozeTemplates",
|
||||
"decks",
|
||||
"fields",
|
||||
"models"
|
||||
],
|
||||
"properties": {
|
||||
"metadata": {
|
||||
"$ref": "#/definitions/metadata"
|
||||
},
|
||||
"standardTemplates": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/standardTemplate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"clozeTemplates": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/clozeTemplate"
|
||||
}
|
||||
}
|
||||
},
|
||||
"decks": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/deck"
|
||||
}
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/field"
|
||||
}
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"type": "object",
|
||||
"patternProperties": {
|
||||
".*": {
|
||||
"$ref": "#/definitions/model"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user