Add JSON input (#3)
All checks were successful
ci/woodpecker/push/build Pipeline was successful
ci/woodpecker/push/all-checks-complete Pipeline was successful

Co-authored-by: Smaug123 <patrick+github@patrickstevens.co.uk>
Reviewed-on: #3
This commit is contained in:
2023-09-08 18:19:06 +00:00
parent 1b1c902667
commit 3e3d092c27
35 changed files with 1407 additions and 113 deletions

View 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>

View File

@@ -0,0 +1,6 @@
module AssemblyInfo
open System.Runtime.CompilerServices
[<assembly : InternalsVisibleTo("AnkiStatic.Test")>]
do ()

24
AnkiStatic.Lib/Base91.fs Normal file
View 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 ()

View 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

View 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}"

View 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}
}}"""

View 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

View 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

View 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
}

View 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

View 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

View 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}
}}"""

View 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
}

View 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
}
}

View 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": ""
}
}

View 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
}
}

View 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

View 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
}

View 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
}
}

View 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
View 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 ()
}

View 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"
}
}
}
}
}