diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json
index 2a80148..9e89cf8 100644
--- a/.config/dotnet-tools.json
+++ b/.config/dotnet-tools.json
@@ -9,4 +9,4 @@
]
}
}
-}
+}
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index 9d99dc6..412cac7 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,5 @@ riderModule.iml
*.DotSettings
.profile*
test.sqlite
+__pycache__/
+.idea
diff --git a/AnkiStatic.Lib/AnkiStatic.Lib.fsproj b/AnkiStatic.Lib/AnkiStatic.Lib.fsproj
new file mode 100644
index 0000000..ed8ef1f
--- /dev/null
+++ b/AnkiStatic.Lib/AnkiStatic.Lib.fsproj
@@ -0,0 +1,35 @@
+
+
+
+ net7.0
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/AnkiStatic.Lib/AssemblyInfo.fs b/AnkiStatic.Lib/AssemblyInfo.fs
new file mode 100644
index 0000000..521512f
--- /dev/null
+++ b/AnkiStatic.Lib/AssemblyInfo.fs
@@ -0,0 +1,6 @@
+module AssemblyInfo
+
+open System.Runtime.CompilerServices
+
+[]
+do ()
diff --git a/AnkiStatic/Base91.fs b/AnkiStatic.Lib/Base91.fs
similarity index 96%
rename from AnkiStatic/Base91.fs
rename to AnkiStatic.Lib/Base91.fs
index f8bdd6e..4c061e8 100644
--- a/AnkiStatic/Base91.fs
+++ b/AnkiStatic.Lib/Base91.fs
@@ -3,7 +3,7 @@ namespace AnkiStatic
open System.Text
[]
-module Base91 =
+module internal Base91 =
// Replicating the Anki algorithm
let private chars =
diff --git a/AnkiStatic/Domain/Card.fs b/AnkiStatic.Lib/Domain/Card.fs
similarity index 100%
rename from AnkiStatic/Domain/Card.fs
rename to AnkiStatic.Lib/Domain/Card.fs
diff --git a/AnkiStatic/Domain/Collection.fs b/AnkiStatic.Lib/Domain/Collection.fs
similarity index 100%
rename from AnkiStatic/Domain/Collection.fs
rename to AnkiStatic.Lib/Domain/Collection.fs
diff --git a/AnkiStatic/Domain/CollectionConfiguration.fs b/AnkiStatic.Lib/Domain/CollectionConfiguration.fs
similarity index 100%
rename from AnkiStatic/Domain/CollectionConfiguration.fs
rename to AnkiStatic.Lib/Domain/CollectionConfiguration.fs
diff --git a/AnkiStatic/Domain/Deck.fs b/AnkiStatic.Lib/Domain/Deck.fs
similarity index 100%
rename from AnkiStatic/Domain/Deck.fs
rename to AnkiStatic.Lib/Domain/Deck.fs
diff --git a/AnkiStatic/Domain/DeckConfiguration.fs b/AnkiStatic.Lib/Domain/DeckConfiguration.fs
similarity index 100%
rename from AnkiStatic/Domain/DeckConfiguration.fs
rename to AnkiStatic.Lib/Domain/DeckConfiguration.fs
diff --git a/AnkiStatic/Domain/Grave.fs b/AnkiStatic.Lib/Domain/Grave.fs
similarity index 100%
rename from AnkiStatic/Domain/Grave.fs
rename to AnkiStatic.Lib/Domain/Grave.fs
diff --git a/AnkiStatic/Domain/Model.fs b/AnkiStatic.Lib/Domain/Model.fs
similarity index 97%
rename from AnkiStatic/Domain/Model.fs
rename to AnkiStatic.Lib/Domain/Model.fs
index b82dcca..ef0ce78 100644
--- a/AnkiStatic/Domain/Model.fs
+++ b/AnkiStatic.Lib/Domain/Model.fs
@@ -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},
diff --git a/AnkiStatic/Domain/Note.fs b/AnkiStatic.Lib/Domain/Note.fs
similarity index 100%
rename from AnkiStatic/Domain/Note.fs
rename to AnkiStatic.Lib/Domain/Note.fs
diff --git a/AnkiStatic/Domain/Review.fs b/AnkiStatic.Lib/Domain/Review.fs
similarity index 90%
rename from AnkiStatic/Domain/Review.fs
rename to AnkiStatic.Lib/Domain/Review.fs
index edaee9f..dae5df1 100644
--- a/AnkiStatic/Domain/Review.fs
+++ b/AnkiStatic.Lib/Domain/Review.fs
@@ -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}
diff --git a/AnkiStatic/Examples/example-collection-conf.json b/AnkiStatic.Lib/Examples/example-collection-conf.json
similarity index 99%
rename from AnkiStatic/Examples/example-collection-conf.json
rename to AnkiStatic.Lib/Examples/example-collection-conf.json
index bbdfc6f..5450acc 100644
--- a/AnkiStatic/Examples/example-collection-conf.json
+++ b/AnkiStatic.Lib/Examples/example-collection-conf.json
@@ -13,4 +13,4 @@
"dueCounts": true,
"curModel": "1373473028447",
"collapseTime": 1200
-}
+}
\ No newline at end of file
diff --git a/AnkiStatic/Examples/example-collection-deck-conf.json b/AnkiStatic.Lib/Examples/example-collection-deck-conf.json
similarity index 99%
rename from AnkiStatic/Examples/example-collection-deck-conf.json
rename to AnkiStatic.Lib/Examples/example-collection-deck-conf.json
index b82ccc8..9c88738 100644
--- a/AnkiStatic/Examples/example-collection-deck-conf.json
+++ b/AnkiStatic.Lib/Examples/example-collection-deck-conf.json
@@ -41,4 +41,4 @@
"id": 1,
"autoplay": true
}
-}
+}
\ No newline at end of file
diff --git a/AnkiStatic/Examples/example-collection-decks.json b/AnkiStatic.Lib/Examples/example-collection-decks.json
similarity index 99%
rename from AnkiStatic/Examples/example-collection-decks.json
rename to AnkiStatic.Lib/Examples/example-collection-decks.json
index 6eaf5cf..de84fe0 100644
--- a/AnkiStatic/Examples/example-collection-decks.json
+++ b/AnkiStatic.Lib/Examples/example-collection-decks.json
@@ -56,4 +56,4 @@
"mod": 1373402705,
"desc": ""
}
-}
+}
\ No newline at end of file
diff --git a/AnkiStatic/Examples/example-collection-models.json b/AnkiStatic.Lib/Examples/example-collection-models.json
similarity index 99%
rename from AnkiStatic/Examples/example-collection-models.json
rename to AnkiStatic.Lib/Examples/example-collection-models.json
index d2a2f8e..52d1289 100644
--- a/AnkiStatic/Examples/example-collection-models.json
+++ b/AnkiStatic.Lib/Examples/example-collection-models.json
@@ -365,4 +365,4 @@
"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
}
-}
+}
\ No newline at end of file
diff --git a/AnkiStatic.Lib/JsonDomain.fs b/AnkiStatic.Lib/JsonDomain.fs
new file mode 100644
index 0000000..2a1f23e
--- /dev/null
+++ b/AnkiStatic.Lib/JsonDomain.fs
@@ -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 ()
+
+ 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 ()
+
+ 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 ()
+
+ 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 ()
+
+ 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
+
+[]
+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 =
+ {
+ // []
+ CreationDate : DateTimeOffset
+ // []
+ DefaultDeck : string
+ // []
+ DefaultDeckConfiguration : SerialisedDeckConfiguration
+ /// Map into the string deck names
+ NonDefaultDecks : IReadOnlyDictionary
+ NonDefaultDeckConfigurations : IReadOnlyDictionary
+ Tags : string
+ // []
+ 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
+ // []
+ SortFieldValue : string
+ // []
+ AdditionalFieldValues : string list
+ CreationDate : DateTimeOffset option
+ // []
+ Model : string
+ }
+
+ static member internal ToInternal
+ (deck : SerialisedDeck)
+ (models : Map)
+ (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
+ // []
+ Description : string
+ // []
+ 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 =
+ {
+ // []
+ 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
+ // []
+ SortField : string
+ // []
+ AdditionalFields : string list
+ LatexPost : string option
+ LatexPre : string option
+ // TODO: is this required?
+ // []
+ Name : string
+ // []
+ Templates : string list
+ // []
+ Type : ModelType
+ DefaultDeck : string option
+ ModificationTime : DateTimeOffset
+ }
+
+ static member internal ToInternal
+ (defaultDeck : SerialisedDeck)
+ (standardTemplates : IReadOnlyDictionary)
+ (clozeTemplates : IReadOnlyDictionary)
+ (decks : Map)
+ (fields : Map)
+ (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 =
+ {
+ // []
+ Metadata : JsonMetadata
+ // []
+ StandardTemplates : IReadOnlyDictionary
+ // []
+ ClozeTemplates : IReadOnlyDictionary
+ /// Map of name to deck
+ // []
+ Decks : IReadOnlyDictionary
+ // []
+ Fields : IReadOnlyDictionary
+ // []
+ Models : IReadOnlyDictionary
+ }
+
+ 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 =
+ 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
+ |> 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
diff --git a/AnkiStatic/SerialisedCard.fs b/AnkiStatic.Lib/SerialisedCard.fs
similarity index 86%
rename from AnkiStatic/SerialisedCard.fs
rename to AnkiStatic.Lib/SerialisedCard.fs
index 1154845..36b0cd6 100644
--- a/AnkiStatic/SerialisedCard.fs
+++ b/AnkiStatic.Lib/SerialisedCard.fs
@@ -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
diff --git a/AnkiStatic/SerialisedCollection.fs b/AnkiStatic.Lib/SerialisedCollection.fs
similarity index 86%
rename from AnkiStatic/SerialisedCollection.fs
rename to AnkiStatic.Lib/SerialisedCollection.fs
index bf9474e..211b60c 100644
--- a/AnkiStatic/SerialisedCollection.fs
+++ b/AnkiStatic.Lib/SerialisedCollection.fs
@@ -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
diff --git a/AnkiStatic/SerialisedDomain.fs b/AnkiStatic.Lib/SerialisedDomain.fs
similarity index 92%
rename from AnkiStatic/SerialisedDomain.fs
rename to AnkiStatic.Lib/SerialisedDomain.fs
index 0c96e6a..6e2ced4 100644
--- a/AnkiStatic/SerialisedDomain.fs
+++ b/AnkiStatic.Lib/SerialisedDomain.fs
@@ -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
diff --git a/AnkiStatic/Sqlite.fs b/AnkiStatic.Lib/Sqlite.fs
similarity index 86%
rename from AnkiStatic/Sqlite.fs
rename to AnkiStatic.Lib/Sqlite.fs
index 35378ff..f6da77c 100644
--- a/AnkiStatic/Sqlite.fs
+++ b/AnkiStatic.Lib/Sqlite.fs
@@ -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
+ =
+ 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 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 ()
+ }
diff --git a/AnkiStatic.Lib/anki.schema.json b/AnkiStatic.Lib/anki.schema.json
new file mode 100644
index 0000000..a5f1645
--- /dev/null
+++ b/AnkiStatic.Lib/anki.schema.json
@@ -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
\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}}
\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"
+ }
+ }
+ }
+ }
+}
\ No newline at end of file
diff --git a/AnkiStatic.Test/AnkiStatic.Test.fsproj b/AnkiStatic.Test/AnkiStatic.Test.fsproj
index 0257b52..857e1f0 100644
--- a/AnkiStatic.Test/AnkiStatic.Test.fsproj
+++ b/AnkiStatic.Test/AnkiStatic.Test.fsproj
@@ -8,8 +8,12 @@
+
+
+
+
@@ -22,7 +26,7 @@
-
+
diff --git a/AnkiStatic.Test/LonghandExample.fs b/AnkiStatic.Test/LonghandExample.fs
index 24d442c..bab4682 100644
--- a/AnkiStatic.Test/LonghandExample.fs
+++ b/AnkiStatic.Test/LonghandExample.fs
@@ -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 outputFile =
+ Path.GetTempFileName ()
+ |> fun f -> Path.ChangeExtension (f, ".apkg")
+ |> FileInfo
- let rng = Random 1
- let buffer = BitConverter.GetBytes (uint64 0)
+ Sqlite.writeAll (Random 1) collection notes outputFile |> fun t -> t.Result
- 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 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
-
- Console.WriteLine $"Written: %s{outputFile.FullName}"
- return ()
- }
+ Console.WriteLine $"Written: %s{outputFile.FullName}"
diff --git a/AnkiStatic.Test/TestEndToEnd.fs b/AnkiStatic.Test/TestEndToEnd.fs
new file mode 100644
index 0000000..7453f9b
--- /dev/null
+++ b/AnkiStatic.Test/TestEndToEnd.fs
@@ -0,0 +1,30 @@
+namespace AnkiStatic.Test
+
+open AnkiStatic
+open NUnit.Framework
+open System
+open System.IO
+
+[]
+module TestEndToEnd =
+ type private Dummy =
+ class
+ end
+
+ []
+ let ``End-to-end test of example1.json`` (fileName : string) =
+ let assembly = typeof.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}"
diff --git a/AnkiStatic.Test/TestJson.fs b/AnkiStatic.Test/TestJson.fs
new file mode 100644
index 0000000..7bea1c1
--- /dev/null
+++ b/AnkiStatic.Test/TestJson.fs
@@ -0,0 +1,20 @@
+namespace AnkiStatic.Test
+
+open NUnit.Framework
+open AnkiStatic
+open FsUnitTyped
+
+[]
+module TestJson =
+
+ type private Dummy =
+ class
+ end
+
+ []
+ let ``Can read example`` () =
+ let assembly = typeof.Assembly
+ let json = Utils.readResource assembly "example1.json"
+
+ let collection, notes = JsonCollection.deserialise json |> JsonCollection.toInternal
+ ()
diff --git a/AnkiStatic.Test/Utils.fs b/AnkiStatic.Test/Utils.fs
new file mode 100644
index 0000000..a6d5dea
--- /dev/null
+++ b/AnkiStatic.Test/Utils.fs
@@ -0,0 +1,21 @@
+namespace AnkiStatic.Test
+
+open System
+open System.IO
+open System.Reflection
+
+[]
+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 ()
diff --git a/AnkiStatic.Test/example1.json b/AnkiStatic.Test/example1.json
new file mode 100644
index 0000000..5ded177
--- /dev/null
+++ b/AnkiStatic.Test/example1.json
@@ -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
\n\n{{Back}}",
+ "browserAnswerFormat": "",
+ "browserQuestionFormat": "",
+ "name": "Card 1",
+ "questionFormat": "{{Front}}"
+ },
+ "back": {
+ "answerFormat": "{{FrontSide}}\n\n
\n\n{{Front}}",
+ "browserAnswerFormat": "",
+ "browserQuestionFormat": "",
+ "name": "Card 2",
+ "questionFormat": "{{Back}}"
+ }
+ },
+ "clozeTemplates": {
+ "clozeTemplate": {
+ "answerFormat": "{{cloze:Text}}
\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"
+ }
+ }
+}
\ No newline at end of file
diff --git a/AnkiStatic.sln b/AnkiStatic.sln
index aa7a5e0..7510807 100644
--- a/AnkiStatic.sln
+++ b/AnkiStatic.sln
@@ -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
diff --git a/AnkiStatic/AnkiStatic.fsproj b/AnkiStatic/AnkiStatic.fsproj
index 69fd721..8c2d5d5 100644
--- a/AnkiStatic/AnkiStatic.fsproj
+++ b/AnkiStatic/AnkiStatic.fsproj
@@ -1,34 +1,17 @@
-
- Exe
- net7.0
-
+
+ Exe
+ net7.0
+
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
diff --git a/flake.lock b/flake.lock
index e0f362f..a464c64 100644
--- a/flake.lock
+++ b/flake.lock
@@ -57,4 +57,4 @@
},
"root": "root",
"version": 7
-}
+}
\ No newline at end of file
diff --git a/hooks/format.py b/hooks/format.py
new file mode 100755
index 0000000..fddbaa0
--- /dev/null
+++ b/hooks/format.py
@@ -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)
diff --git a/hooks/pre-push b/hooks/pre-push
index 4f7ce2f..9406994 100755
--- a/hooks/pre-push
+++ b/hooks/pre-push
@@ -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__":