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__":