Add JSON input (#3)
Co-authored-by: Smaug123 <patrick+github@patrickstevens.co.uk> Reviewed-on: #3
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ riderModule.iml
|
||||
*.DotSettings
|
||||
.profile*
|
||||
test.sqlite
|
||||
__pycache__/
|
||||
.idea
|
||||
|
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 ()
|
@@ -3,7 +3,7 @@ namespace AnkiStatic
|
||||
open System.Text
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Base91 =
|
||||
module internal Base91 =
|
||||
|
||||
// Replicating the Anki algorithm
|
||||
let private chars =
|
@@ -78,7 +78,7 @@ type ModelType =
|
||||
type ModelConfiguration<'Deck> =
|
||||
{
|
||||
Css : string
|
||||
DeckId : 'Deck
|
||||
DefaultDeckId : 'Deck
|
||||
Fields : ModelField list
|
||||
/// String which is added to terminate LaTeX expressions
|
||||
LatexPost : string
|
||||
@@ -125,7 +125,7 @@ module ModelConfiguration =
|
||||
"vers": %s{vers},
|
||||
"name": %s{JsonSerializer.Serialize this.Name},
|
||||
"tags": %s{tags},
|
||||
"did": %i{this.DeckId.ToUnixTimeMilliseconds ()},
|
||||
"did": %i{this.DefaultDeckId.ToUnixTimeMilliseconds ()},
|
||||
"usn": %i{this.UpdateSequenceNumber},
|
||||
"flds": %s{flds},
|
||||
"sortf": %i{this.SortField},
|
@@ -18,7 +18,7 @@ type ReviewConfiguration =
|
||||
$"""{{
|
||||
"perDay": %i{this.PerDay},
|
||||
"ivlFct": %i{this.IntervalFactor},
|
||||
"maxIvl": %i{int this.MaxInterval.TotalDays * 100},
|
||||
"maxIvl": %i{int this.MaxInterval.TotalDays},
|
||||
"minSpace": %i{this.MinSpace},
|
||||
"ease4": %f{this.EasinessPerEasyReview},
|
||||
"fuzz": %f{this.Fuzz}
|
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
|
@@ -1,9 +1,11 @@
|
||||
namespace AnkiStatic
|
||||
|
||||
open System
|
||||
open System.Text.RegularExpressions
|
||||
|
||||
type SerialisedNote =
|
||||
{
|
||||
Deck : SerialisedDeck
|
||||
CreationDate : DateTimeOffset
|
||||
Model : SerialisedModel
|
||||
Tags : string list
|
||||
@@ -63,8 +65,17 @@ module SerialisedNote =
|
||||
}
|
||||
|
||||
let otherCards =
|
||||
note.Model.AdditionalFields
|
||||
|> List.mapi (fun i _field ->
|
||||
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
|
@@ -51,16 +51,25 @@ module SerialisedCollection =
|
||||
|
||||
decks, deckLookup
|
||||
|
||||
let models, modelLookup =
|
||||
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 (fst collection.DefaultModel) (snd collection.DefaultModel)
|
||||
|> Map.add defaultModelDate (snd collection.DefaultModel)
|
||||
|> Map.map (fun modelTimestamp v ->
|
||||
let deckTimestamp, _deck = deckLookup v.Deck
|
||||
let defaultDeckTimestamp, _deck = deckLookup v.DefaultDeck
|
||||
dict.Add (v, modelTimestamp)
|
||||
SerialisedModel.ToModel v deckTimestamp
|
||||
SerialisedModel.ToModel v defaultDeckTimestamp
|
||||
)
|
||||
|
||||
let modelLookup (m : SerialisedModel) : DateTimeOffset =
|
||||
@@ -70,7 +79,7 @@ module SerialisedCollection =
|
||||
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
|
||||
models, modelLookup, defaultModelDate
|
||||
|
||||
let defaultDeck, _ = deckLookup collection.DefaultDeck
|
||||
|
@@ -82,15 +82,23 @@ type SerialisedModel =
|
||||
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
|
||||
Deck : SerialisedDeck
|
||||
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
|
||||
DeckId = deck
|
||||
DefaultDeckId = deck
|
||||
Fields =
|
||||
(s.SortField :: s.AdditionalFields)
|
||||
|> List.mapi SerialisedModelField.ToModelField
|
@@ -3,6 +3,7 @@ namespace AnkiStatic
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open Microsoft.Data.Sqlite
|
||||
open System.Threading.Tasks
|
||||
|
||||
@@ -350,3 +351,65 @@ VALUES (@id, @nid, @did, @ord, @mod, @usn, @type, @queue, @due, @ivl, @factor, @
|
||||
|
||||
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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
@@ -8,8 +8,12 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Utils.fs" />
|
||||
<Compile Include="LonghandExample.fs"/>
|
||||
<Compile Include="Tests.fs" />
|
||||
<Compile Include="TestJson.fs" />
|
||||
<Compile Include="TestEndToEnd.fs" />
|
||||
<EmbeddedResource Include="example1.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -22,7 +26,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\AnkiStatic\AnkiStatic.fsproj"/>
|
||||
<ProjectReference Include="..\AnkiStatic.Lib\AnkiStatic.Lib.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
@@ -1,9 +1,7 @@
|
||||
namespace AnkiStatic.Test
|
||||
|
||||
open System
|
||||
open System.Collections.Generic
|
||||
open System.IO
|
||||
open System.IO.Compression
|
||||
open AnkiStatic
|
||||
open NUnit.Framework
|
||||
|
||||
@@ -86,7 +84,7 @@ module Example =
|
||||
SortField = frontField
|
||||
Templates = [ frontTemplate ; backTemplate ]
|
||||
Type = ModelType.Standard
|
||||
Deck = deck
|
||||
DefaultDeck = deck
|
||||
}
|
||||
|
||||
let textField : SerialisedModelField =
|
||||
@@ -128,7 +126,7 @@ module Example =
|
||||
SortField = textField
|
||||
Templates = [ clozeTemplate ]
|
||||
Type = ModelType.Cloze
|
||||
Deck = deck
|
||||
DefaultDeck = deck
|
||||
}
|
||||
|
||||
let example : SerialisedCollection =
|
||||
@@ -176,7 +174,7 @@ module Example =
|
||||
EasinessPerEasyReview = 1.3
|
||||
Fuzz = 0.05
|
||||
IntervalFactor = 1
|
||||
MaxInterval = TimeSpan.FromDays 365.0
|
||||
MaxInterval = TimeSpan.FromDays 36500.0
|
||||
PerDay = 100
|
||||
}
|
||||
ShowTimer = false
|
||||
@@ -200,6 +198,7 @@ module Example =
|
||||
ValueOfSortField = "Definition of the logistic function"
|
||||
ValuesOfAdditionalFields = [ @"\(g(z) = \frac{1}{1+e^{-z}}\)" ]
|
||||
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
||||
Deck = deck
|
||||
}
|
||||
{
|
||||
Model = clozeModel
|
||||
@@ -208,67 +207,15 @@ module Example =
|
||||
"The four perspectives of Ithkuil are {{c1::monadic}}, {{c2::unbounded}}, {{c3::nomic}}, {{c4::abstract}}."
|
||||
ValuesOfAdditionalFields = [ "" ]
|
||||
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
||||
Deck = deck
|
||||
}
|
||||
]
|
||||
|
||||
let renderedNotes, lookupNote =
|
||||
let dict = Dictionary ()
|
||||
|
||||
let rng = Random 1
|
||||
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 file = Path.GetTempFileName () |> FileInfo
|
||||
|
||||
task {
|
||||
let! package = Sqlite.createEmptyPackage file
|
||||
|
||||
let! written = collection.Collection |> Sqlite.createDecks package
|
||||
|
||||
let! noteIds = Sqlite.createNotes written renderedNotes
|
||||
|
||||
let _, _, cards =
|
||||
((0, 0, []), notes)
|
||||
||> List.fold (fun (count, iter, cards) note ->
|
||||
let built =
|
||||
SerialisedNote.buildCards count 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! Sqlite.createCards written cards
|
||||
|
||||
let outputFile =
|
||||
Path.GetTempFileName ()
|
||||
|> fun f -> Path.ChangeExtension (f, ".apkg")
|
||||
|> FileInfo
|
||||
|
||||
use outputStream = outputFile.OpenWrite ()
|
||||
use archive = new ZipArchive (outputStream, ZipArchiveMode.Create, true)
|
||||
|
||||
let entry = archive.CreateEntry "collection.anki2"
|
||||
use entryStream = entry.Open ()
|
||||
use contents = file.OpenRead ()
|
||||
do! contents.CopyToAsync entryStream
|
||||
Sqlite.writeAll (Random 1) collection notes outputFile |> fun t -> t.Result
|
||||
|
||||
Console.WriteLine $"Written: %s{outputFile.FullName}"
|
||||
return ()
|
||||
}
|
||||
|
30
AnkiStatic.Test/TestEndToEnd.fs
Normal file
30
AnkiStatic.Test/TestEndToEnd.fs
Normal file
@@ -0,0 +1,30 @@
|
||||
namespace AnkiStatic.Test
|
||||
|
||||
open AnkiStatic
|
||||
open NUnit.Framework
|
||||
open System
|
||||
open System.IO
|
||||
|
||||
[<TestFixture>]
|
||||
module TestEndToEnd =
|
||||
type private Dummy =
|
||||
class
|
||||
end
|
||||
|
||||
[<TestCase "example1.json">]
|
||||
let ``End-to-end test of example1.json`` (fileName : string) =
|
||||
let assembly = typeof<Dummy>.Assembly
|
||||
let json = Utils.readResource assembly fileName
|
||||
|
||||
let collection, notes = JsonCollection.deserialise json |> JsonCollection.toInternal
|
||||
|
||||
let outputFile =
|
||||
Path.GetTempFileName ()
|
||||
|> fun f -> Path.ChangeExtension (f, ".apkg")
|
||||
|> FileInfo
|
||||
|
||||
let collection = SerialisedCollection.toSqlite collection
|
||||
|
||||
Sqlite.writeAll (Random 1) collection notes outputFile |> fun t -> t.Result
|
||||
|
||||
Console.WriteLine $"Written file: %s{outputFile.FullName}"
|
20
AnkiStatic.Test/TestJson.fs
Normal file
20
AnkiStatic.Test/TestJson.fs
Normal file
@@ -0,0 +1,20 @@
|
||||
namespace AnkiStatic.Test
|
||||
|
||||
open NUnit.Framework
|
||||
open AnkiStatic
|
||||
open FsUnitTyped
|
||||
|
||||
[<TestFixture>]
|
||||
module TestJson =
|
||||
|
||||
type private Dummy =
|
||||
class
|
||||
end
|
||||
|
||||
[<Test>]
|
||||
let ``Can read example`` () =
|
||||
let assembly = typeof<Dummy>.Assembly
|
||||
let json = Utils.readResource assembly "example1.json"
|
||||
|
||||
let collection, notes = JsonCollection.deserialise json |> JsonCollection.toInternal
|
||||
()
|
21
AnkiStatic.Test/Utils.fs
Normal file
21
AnkiStatic.Test/Utils.fs
Normal file
@@ -0,0 +1,21 @@
|
||||
namespace AnkiStatic.Test
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Reflection
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Utils =
|
||||
|
||||
let readResource (assembly : Assembly) (name : string) : string =
|
||||
let names =
|
||||
assembly.GetManifestResourceNames ()
|
||||
|> Seq.filter (fun s -> s.EndsWith (name, StringComparison.Ordinal))
|
||||
|
||||
use stream =
|
||||
names
|
||||
|> Seq.exactlyOne
|
||||
|> assembly.GetManifestResourceStream
|
||||
|> fun s -> new StreamReader (s)
|
||||
|
||||
stream.ReadToEnd ()
|
171
AnkiStatic.Test/example1.json
Normal file
171
AnkiStatic.Test/example1.json
Normal file
@@ -0,0 +1,171 @@
|
||||
{
|
||||
"metadata": {
|
||||
"creationDate": "2023-09-06T19:30:00+01:00",
|
||||
"collapseTimeSeconds": 1200,
|
||||
"estimateTimes": false,
|
||||
"newSpread": "distribute",
|
||||
"showDueCounts": true,
|
||||
"sortBackwards": false,
|
||||
"defaultDeck": "Analysis",
|
||||
"defaultDeckConfiguration": {
|
||||
"autoPlay": true,
|
||||
"lapse": {
|
||||
"delays": [
|
||||
10
|
||||
],
|
||||
"leechAction": "suspend",
|
||||
"leechFails": 8,
|
||||
"minInterval": 1,
|
||||
"multiplier": 0
|
||||
},
|
||||
"name": "Default",
|
||||
"new": {
|
||||
"delays": [
|
||||
1,
|
||||
10
|
||||
],
|
||||
"initialEase": 2500,
|
||||
"intervals": {
|
||||
"good": 1,
|
||||
"easy": 4,
|
||||
"unused": 7
|
||||
},
|
||||
"order": "random",
|
||||
"maxNewPerDay": 20
|
||||
},
|
||||
"replayQuestionAudioWithAnswer": true,
|
||||
"review": {
|
||||
"easinessPerEasyReview": 1.3,
|
||||
"fuzz": 0.05,
|
||||
"intervalFactor": 1,
|
||||
"maxIntervalDays": 36500,
|
||||
"perDay": 100
|
||||
},
|
||||
"showTimer": false,
|
||||
"maxTimerTimeoutSeconds": 60
|
||||
},
|
||||
"nonDefaultDecks": {},
|
||||
"nonDefaultDeckConfigurations": {},
|
||||
"tags": "{}",
|
||||
"defaultModelName": "basicAndReverse"
|
||||
},
|
||||
"standardTemplates": {
|
||||
"front": {
|
||||
"answerFormat": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||
"browserAnswerFormat": "",
|
||||
"browserQuestionFormat": "",
|
||||
"name": "Card 1",
|
||||
"questionFormat": "{{Front}}"
|
||||
},
|
||||
"back": {
|
||||
"answerFormat": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
|
||||
"browserAnswerFormat": "",
|
||||
"browserQuestionFormat": "",
|
||||
"name": "Card 2",
|
||||
"questionFormat": "{{Back}}"
|
||||
}
|
||||
},
|
||||
"clozeTemplates": {
|
||||
"clozeTemplate": {
|
||||
"answerFormat": "{{cloze:Text}}<br>\n{{Extra}}",
|
||||
"browserAnswerFormat": "",
|
||||
"browserQuestionFormat": "",
|
||||
"name": "Cloze",
|
||||
"questionFormat": "{{cloze:Text}}"
|
||||
}
|
||||
},
|
||||
"decks": {
|
||||
"Analysis": {
|
||||
"extendedReviewLimit": 50,
|
||||
"extendedNewCardLimit": 10,
|
||||
"collapsed": false,
|
||||
"browserCollapsed": false,
|
||||
"description": "",
|
||||
"notes": [
|
||||
{
|
||||
"tags": [],
|
||||
"sortFieldValue": "Definition of the logistic function",
|
||||
"additionalFieldValues": [
|
||||
"\\(g(z) = \\frac{1}{1+e^{-z}}\\)"
|
||||
],
|
||||
"creationDate": "2023-09-06T19:30:00+01:00",
|
||||
"model": "basicAndReverse"
|
||||
},
|
||||
{
|
||||
"tags": [],
|
||||
"sortFieldValue": "The four perspectives of Ithkuil are {{c1::monadic}}, {{c2::unbounded}}, {{c3::nomic}}, {{c4::abstract}}.",
|
||||
"additionalFieldValues": [
|
||||
""
|
||||
],
|
||||
"creationDate": "2023-09-06T19:30:00.001+01:00",
|
||||
"model": "cloze"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"fields": {
|
||||
"frontField": {
|
||||
"font": "Arial",
|
||||
"displayName": "Front",
|
||||
"rightToLeft": false,
|
||||
"fontSize": 20,
|
||||
"sticky": false
|
||||
},
|
||||
"backField": {
|
||||
"displayName": "Back",
|
||||
"font": "Arial",
|
||||
"rightToLeft": false,
|
||||
"fontSize": 20,
|
||||
"sticky": false
|
||||
},
|
||||
"text": {
|
||||
"displayName": "Text",
|
||||
"font": "Arial",
|
||||
"rightToLeft": false,
|
||||
"fontSize": 20,
|
||||
"sticky": false
|
||||
},
|
||||
"extra": {
|
||||
"displayName": "Extra",
|
||||
"font": "Arial",
|
||||
"rightToLeft": false,
|
||||
"fontSize": 20,
|
||||
"sticky": false
|
||||
}
|
||||
},
|
||||
"models": {
|
||||
"cloze": {
|
||||
"modificationTime": "2013-07-10T17:17:08.440+01:00",
|
||||
"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}",
|
||||
"sortField": "text",
|
||||
"additionalFields": [
|
||||
"extra"
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"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",
|
||||
"name": "Cloze",
|
||||
"templates": [
|
||||
"clozeTemplate"
|
||||
],
|
||||
"type": "cloze",
|
||||
"defaultDeck": "Analysis"
|
||||
},
|
||||
"basicAndReverse": {
|
||||
"modificationTime": "2013-07-10T17:17:08.445+01:00",
|
||||
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||
"sortField": "frontField",
|
||||
"additionalFields": [
|
||||
"backField"
|
||||
],
|
||||
"latexPost": "\\end{document}",
|
||||
"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",
|
||||
"name": "Basic (and reversed card)",
|
||||
"templates": [
|
||||
"front",
|
||||
"back"
|
||||
],
|
||||
"type": "standard",
|
||||
"defaultDeck": "Analysis"
|
||||
}
|
||||
}
|
||||
}
|
@@ -1,9 +1,11 @@
|
||||
|
||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic", "AnkiStatic\AnkiStatic.fsproj", "{74D45DA0-912E-45B0-9832-EAE763493431}"
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic.Lib", "AnkiStatic.Lib\AnkiStatic.Lib.fsproj", "{74D45DA0-912E-45B0-9832-EAE763493431}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic.Test", "AnkiStatic.Test\AnkiStatic.Test.fsproj", "{042891EC-592B-443D-B5EA-847AE1FA9E2B}"
|
||||
EndProject
|
||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic", "AnkiStatic\AnkiStatic.fsproj", "{819FF90C-F165-4E9B-96E9-A5BBDA80261E}"
|
||||
EndProject
|
||||
Global
|
||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||
Debug|Any CPU = Debug|Any CPU
|
||||
@@ -18,5 +20,9 @@ Global
|
||||
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||
EndGlobalSection
|
||||
EndGlobal
|
||||
|
@@ -5,30 +5,13 @@
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
</PropertyGroup>
|
||||
|
||||
|
||||
<ItemGroup>
|
||||
<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="Base91.fs" />
|
||||
<Compile Include="Sqlite.fs"/>
|
||||
<Compile Include="Program.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"/>
|
||||
<Compile Include="Program.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.10"/>
|
||||
<ProjectReference Include="..\AnkiStatic.Lib\AnkiStatic.Lib.fsproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
53
hooks/format.py
Executable file
53
hooks/format.py
Executable file
@@ -0,0 +1,53 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
# Returns False if the file was changed, True if it didn't need changing,
|
||||
# and None if the file was inaccessible for some reason.
|
||||
def format_json_file(filepath, check_only=False):
|
||||
try:
|
||||
with open(filepath, 'r') as f:
|
||||
original_data = f.read()
|
||||
parsed_data = json.loads(original_data)
|
||||
except FileNotFoundError:
|
||||
print(f"File {filepath} not found.")
|
||||
return None
|
||||
except json.JSONDecodeError:
|
||||
print(f"File {filepath} contains invalid JSON.")
|
||||
return None
|
||||
|
||||
formatted_data = json.dumps(parsed_data, indent=2)
|
||||
|
||||
if check_only:
|
||||
return original_data == formatted_data
|
||||
|
||||
if original_data != formatted_data:
|
||||
with tempfile.NamedTemporaryFile('w', delete=False) as temp:
|
||||
temp.write(formatted_data)
|
||||
temp_filename = temp.name
|
||||
|
||||
os.rename(temp_filename, filepath)
|
||||
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
import sys
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Format JSON files.")
|
||||
parser.add_argument("filepaths", nargs='+', help="Path to JSON files to format")
|
||||
parser.add_argument("--check", action="store_true", help="Only check if files are correctly formatted")
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
all_files_ok = True
|
||||
|
||||
for filepath in args.filepaths:
|
||||
success = format_json_file(filepath, args.check)
|
||||
if not success:
|
||||
all_files_ok = False
|
||||
|
||||
sys.exit(0 if all_files_ok else 1)
|
@@ -1,6 +1,10 @@
|
||||
#!/usr/bin/env python3
|
||||
|
||||
import subprocess
|
||||
import sys, os
|
||||
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||||
sys.path.insert(0, current_directory)
|
||||
from format import format_json_file
|
||||
|
||||
def check_fantomas():
|
||||
result = subprocess.run(["dotnet", "tool", "run", "fantomas", "--check", "."])
|
||||
@@ -16,9 +20,26 @@ def check_alejandra():
|
||||
raise Exception(f"Formatting incomplete (return code: {result.returncode}). Consider running `alejandra *.nix`")
|
||||
|
||||
|
||||
def check_json():
|
||||
all_ok = True
|
||||
for root, _, files in os.walk(os.getcwd()):
|
||||
for file in files:
|
||||
# NuGet outputs invalid JSON files :facepalm:
|
||||
if file.endswith(".json") and not file.endswith("project.packagespec.json"):
|
||||
full_path = os.path.join(root, file)
|
||||
is_formatted = format_json_file(full_path, check_only = True)
|
||||
if not is_formatted:
|
||||
print(f"File {full_path} is not formatted")
|
||||
all_ok = all_ok and is_formatted
|
||||
|
||||
if not all_ok:
|
||||
raise Exception(f"Formatting incomplete in JSON files. Consider running `find . -type f -name '*.json' | grep -v 'packagespec.json' | xargs hooks/format.py`")
|
||||
|
||||
|
||||
def main():
|
||||
check_fantomas()
|
||||
check_alejandra()
|
||||
check_json()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
Reference in New Issue
Block a user