From ecd168d284a74062dbdd72abbff644296014dffb Mon Sep 17 00:00:00 2001 From: Smaug123 Date: Wed, 6 Sep 2023 18:28:06 +0100 Subject: [PATCH] Initial commit --- .config/dotnet-tools.json | 12 + .editorconfig | 41 ++ .gitattributes | 4 + .gitignore | 10 + .woodpecker/.all-checks-complete.yml | 10 + .woodpecker/.build.yml | 15 + AnkiStatic.Test/AnkiStatic.Test.fsproj | 28 ++ AnkiStatic.Test/LonghandExample.fs | 274 +++++++++++++ AnkiStatic.Test/Tests.fs | 29 ++ AnkiStatic.sln | 22 ++ AnkiStatic/AnkiStatic.fsproj | 34 ++ AnkiStatic/Base91.fs | 24 ++ AnkiStatic/Domain/Card.fs | 99 +++++ AnkiStatic/Domain/Collection.fs | 53 +++ AnkiStatic/Domain/CollectionConfiguration.fs | 50 +++ AnkiStatic/Domain/Deck.fs | 58 +++ AnkiStatic/Domain/DeckConfiguration.fs | 130 +++++++ AnkiStatic/Domain/Grave.fs | 19 + AnkiStatic/Domain/Model.fs | 143 +++++++ AnkiStatic/Domain/Note.fs | 43 ++ AnkiStatic/Domain/Review.fs | 25 ++ .../Examples/example-collection-conf.json | 16 + .../example-collection-deck-conf.json | 44 +++ .../Examples/example-collection-decks.json | 59 +++ .../Examples/example-collection-models.json | 368 ++++++++++++++++++ AnkiStatic/Program.fs | 11 + AnkiStatic/SerialisedCard.fs | 117 ++++++ AnkiStatic/SerialisedCollection.fs | 108 +++++ AnkiStatic/SerialisedDomain.fs | 210 ++++++++++ AnkiStatic/Sqlite.fs | 352 +++++++++++++++++ flake.lock | 60 +++ flake.nix | 28 ++ hooks/pre-push | 25 ++ 33 files changed, 2521 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .woodpecker/.all-checks-complete.yml create mode 100644 .woodpecker/.build.yml create mode 100644 AnkiStatic.Test/AnkiStatic.Test.fsproj create mode 100644 AnkiStatic.Test/LonghandExample.fs create mode 100644 AnkiStatic.Test/Tests.fs create mode 100644 AnkiStatic.sln create mode 100644 AnkiStatic/AnkiStatic.fsproj create mode 100644 AnkiStatic/Base91.fs create mode 100644 AnkiStatic/Domain/Card.fs create mode 100644 AnkiStatic/Domain/Collection.fs create mode 100644 AnkiStatic/Domain/CollectionConfiguration.fs create mode 100644 AnkiStatic/Domain/Deck.fs create mode 100644 AnkiStatic/Domain/DeckConfiguration.fs create mode 100644 AnkiStatic/Domain/Grave.fs create mode 100644 AnkiStatic/Domain/Model.fs create mode 100644 AnkiStatic/Domain/Note.fs create mode 100644 AnkiStatic/Domain/Review.fs create mode 100644 AnkiStatic/Examples/example-collection-conf.json create mode 100644 AnkiStatic/Examples/example-collection-deck-conf.json create mode 100644 AnkiStatic/Examples/example-collection-decks.json create mode 100644 AnkiStatic/Examples/example-collection-models.json create mode 100644 AnkiStatic/Program.fs create mode 100644 AnkiStatic/SerialisedCard.fs create mode 100644 AnkiStatic/SerialisedCollection.fs create mode 100644 AnkiStatic/SerialisedDomain.fs create mode 100644 AnkiStatic/Sqlite.fs create mode 100644 flake.lock create mode 100644 flake.nix create mode 100755 hooks/pre-push diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..2a80148 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "fantomas": { + "version": "6.2.0", + "commands": [ + "fantomas" + ] + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..9ef5fed --- /dev/null +++ b/.editorconfig @@ -0,0 +1,41 @@ +root=true + +[*] +charset=utf-8 +end_of_line=crlf +trim_trailing_whitespace=true +insert_final_newline=true +indent_style=space +indent_size=4 + +# ReSharper properties +resharper_xml_indent_size=2 +resharper_xml_max_line_length=100 +resharper_xml_tab_width=2 + +[*.{csproj,fsproj,sqlproj,targets,props,ts,tsx,css,json}] +indent_style=space +indent_size=2 + +[*.{fs,fsi}] +fsharp_bar_before_discriminated_union_declaration=true +fsharp_space_before_uppercase_invocation=true +fsharp_space_before_class_constructor=true +fsharp_space_before_member=true +fsharp_space_before_colon=true +fsharp_space_before_semicolon=true +fsharp_multiline_bracket_style=aligned +fsharp_newline_between_type_definition_and_members=true +fsharp_align_function_signature_to_indentation=true +fsharp_alternative_long_member_definitions=true +fsharp_multi_line_lambda_closing_newline=true +fsharp_experimental_keep_indent_in_branch=true +fsharp_max_value_binding_width=80 +fsharp_max_record_width=0 +max_line_length=120 +end_of_line=lf + +[*.{appxmanifest,build,dtd,nuspec,xaml,xamlx,xoml,xsd}] +indent_style=space +indent_size=2 +tab_width=2 diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..da9af1e --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.sh text eol=lf +*.nix text eol=lf +hooks/pre-push text eol=lf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9d99dc6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +bin/ +obj/ +/packages/ +riderModule.iml +/_ReSharper.Caches/ +.idea/ +*.user +*.DotSettings +.profile* +test.sqlite diff --git a/.woodpecker/.all-checks-complete.yml b/.woodpecker/.all-checks-complete.yml new file mode 100644 index 0000000..5a7fdd7 --- /dev/null +++ b/.woodpecker/.all-checks-complete.yml @@ -0,0 +1,10 @@ +steps: + echo: + image: alpine + commands: + - echo "All required checks complete" + +depends_on: + - build + +skip_clone: true diff --git a/.woodpecker/.build.yml b/.woodpecker/.build.yml new file mode 100644 index 0000000..4cf18a4 --- /dev/null +++ b/.woodpecker/.build.yml @@ -0,0 +1,15 @@ +steps: + build: + image: nixos/nix + commands: + - echo 'experimental-features = flakes nix-command' >> /etc/nix/nix.conf + # Lint + - "nix develop --command bash -c 'dotnet tool restore && ./hooks/pre-push'" + # Test + - nix develop --command dotnet -- test + - nix develop --command dotnet -- test --configuration Release + + when: + - event: "push" + evaluate: 'CI_COMMIT_BRANCH == CI_REPO_DEFAULT_BRANCH' + - event: "pull_request" diff --git a/AnkiStatic.Test/AnkiStatic.Test.fsproj b/AnkiStatic.Test/AnkiStatic.Test.fsproj new file mode 100644 index 0000000..0257b52 --- /dev/null +++ b/AnkiStatic.Test/AnkiStatic.Test.fsproj @@ -0,0 +1,28 @@ + + + + net7.0 + + false + true + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiStatic.Test/LonghandExample.fs b/AnkiStatic.Test/LonghandExample.fs new file mode 100644 index 0000000..24d442c --- /dev/null +++ b/AnkiStatic.Test/LonghandExample.fs @@ -0,0 +1,274 @@ +namespace AnkiStatic.Test + +open System +open System.Collections.Generic +open System.IO +open System.IO.Compression +open AnkiStatic +open NUnit.Framework + +[] +module Example = + + let incrementArr (arr : byte[]) = + let rec go (pos : int) = + let v = arr.[pos] + + if v < 255uy then + arr.[pos] <- v + 1uy + else + arr.[pos] <- 0uy + + if pos = 0 then + failwith "could not increment max guid" + + go (pos - 1) + + go (arr.Length - 1) + + [] + let example () = + let frontField : SerialisedModelField = + { + Font = "Arial" + Name = "Front" + RightToLeft = false + FontSize = 20 + Sticky = false + } + + let backField : SerialisedModelField = + { + Font = "Arial" + Name = "Back" + RightToLeft = false + FontSize = 20 + Sticky = false + } + + let frontTemplate : SerialisedCardTemplate = + { + AnswerFormat = "{{FrontSide}}\n\n
\n\n{{Back}}" + BrowserAnswerFormat = "" + BrowserQuestionFormat = "" + Name = "Card 1" + QuestionFormat = "{{Front}}" + } + + let backTemplate : SerialisedCardTemplate = + { + AnswerFormat = "{{FrontSide}}\n\n
\n\n{{Front}}" + BrowserAnswerFormat = "" + BrowserQuestionFormat = "" + Name = "Card 2" + QuestionFormat = "{{Back}}" + } + + let deck = + { + Name = "Analysis" + ExtendedReviewLimit = Some 50 + ExtendedNewCardLimit = Some 10 + Collapsed = false + BrowserCollapsed = false + Description = "" + } + + let basicAndReverseModel : SerialisedModel = + { + Css = + ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n" + 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)" + SortField = frontField + Templates = [ frontTemplate ; backTemplate ] + Type = ModelType.Standard + Deck = deck + } + + let textField : SerialisedModelField = + { + Font = "Arial" + Name = "Text" + RightToLeft = false + FontSize = 20 + Sticky = false + } + + let extraField : SerialisedModelField = + { + Font = "Arial" + Name = "Extra" + RightToLeft = false + FontSize = 20 + Sticky = false + } + + let clozeTemplate : SerialisedCardTemplate = + { + AnswerFormat = "{{cloze:Text}}
\n{{Extra}}" + BrowserAnswerFormat = "" + BrowserQuestionFormat = "" + Name = "Cloze" + QuestionFormat = "{{cloze:Text}}" + } + + let clozeModel : SerialisedModel = + { + 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}" + AdditionalFields = [ extraField ] + 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" + SortField = textField + Templates = [ clozeTemplate ] + Type = ModelType.Cloze + Deck = deck + } + + let example : SerialisedCollection = + { + CreationDate = DateTimeOffset (2023, 09, 06, 17, 03, 00, TimeSpan.FromHours 1.0) + Configuration = + { + NewSpread = NewCardDistribution.Distribute + CollapseTime = 1200 + TimeLimit = TimeSpan.Zero + EstimateTimes = true + ShowDueCounts = true + SortBackwards = false + } + DefaultDeck = deck + NonDefaultDecks = Map.empty + DefaultDeckConfiguration = + { + AutoPlay = true + Lapse = + { + Delays = [ 10 ] + LeechAction = LeechAction.Suspend + LeechFails = 8 + MinInterval = 1 + Multiplier = 0 + } + Name = "Default" + New = + { + Delays = [ 1 ; 10 ] + InitialEase = 2500 + Intervals = + { + Good = 1 + Easy = 4 + Unused = 7 + } + Order = NewCardOrder.Random + MaxNewPerDay = 20 + } + ReplayQuestionAudioWithAnswer = true + Review = + { + EasinessPerEasyReview = 1.3 + Fuzz = 0.05 + IntervalFactor = 1 + MaxInterval = TimeSpan.FromDays 365.0 + PerDay = 100 + } + ShowTimer = false + MaxTimerTimeout = TimeSpan.FromSeconds 60.0 + } + NonDefaultDeckConfiguration = Map.empty + Tags = "{}" + DefaultModel = DateTimeOffset.FromUnixTimeMilliseconds 1373473028445L, basicAndReverseModel + NonDefaultModels = + [ DateTimeOffset.FromUnixTimeMilliseconds 1373473028440L, clozeModel ] + |> Map.ofList + } + + let collection = SerialisedCollection.toSqlite example + + let notes : SerialisedNote list = + [ + { + Model = basicAndReverseModel + Tags = [] + 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) + } + { + Model = clozeModel + Tags = [] + ValueOfSortField = + "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) + } + ] + + let renderedNotes, lookupNote = + let dict = Dictionary () + + let rng = Random 1 + let buffer = BitConverter.GetBytes (uint64 0) + + let result = + notes + |> List.mapi (fun i note -> + rng.NextBytes buffer + let guid = BitConverter.ToUInt64 (buffer, 0) + dict.Add (note, i) + SerialisedNote.ToNote guid collection.ModelsInverse note + ) + + let lookupNote (note : SerialisedNote) : int = + match dict.TryGetValue note with + | true, v -> v + | false, _ -> + failwith + $"A card declared that it was associated with a note, but that note was not inserted.\nDesired: %+A{note}\nAvailable:\n%+A{dict}" + + result, lookupNote + + let file = Path.GetTempFileName () |> FileInfo + + task { + let! package = Sqlite.createEmptyPackage file + + let! written = collection.Collection |> Sqlite.createDecks package + + let! noteIds = Sqlite.createNotes written renderedNotes + + let _, _, cards = + ((0, 0, []), notes) + ||> List.fold (fun (count, iter, cards) note -> + let built = + SerialisedNote.buildCards count deck 1000 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 () + } diff --git a/AnkiStatic.Test/Tests.fs b/AnkiStatic.Test/Tests.fs new file mode 100644 index 0000000..d9fba96 --- /dev/null +++ b/AnkiStatic.Test/Tests.fs @@ -0,0 +1,29 @@ +namespace AnkiStatic.Test + +open System +open AnkiStatic +open FsUnitTyped +open NUnit.Framework +open System.Text +open System.Security.Cryptography + +[] +module Tests = + + [] + let ``Checksum matches`` (str : string, expected : uint32) = + let data : Note = + { + Guid = 0uL + ModelId = () + LastModified = DateTimeOffset.UnixEpoch + UpdateSequenceNumber = 0 + Tags = [] + Fields = [ str ] + SortField = Choice2Of2 0 + Flags = 0 + Data = "" + } + + // Obtained from reading an example in the wild + data.Checksum |> shouldEqual expected diff --git a/AnkiStatic.sln b/AnkiStatic.sln new file mode 100644 index 0000000..aa7a5e0 --- /dev/null +++ b/AnkiStatic.sln @@ -0,0 +1,22 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic", "AnkiStatic\AnkiStatic.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 +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {74D45DA0-912E-45B0-9832-EAE763493431}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {74D45DA0-912E-45B0-9832-EAE763493431}.Debug|Any CPU.Build.0 = Debug|Any CPU + {74D45DA0-912E-45B0-9832-EAE763493431}.Release|Any CPU.ActiveCfg = Release|Any CPU + {74D45DA0-912E-45B0-9832-EAE763493431}.Release|Any CPU.Build.0 = Release|Any CPU + {042891EC-592B-443D-B5EA-847AE1FA9E2B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {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 + EndGlobalSection +EndGlobal diff --git a/AnkiStatic/AnkiStatic.fsproj b/AnkiStatic/AnkiStatic.fsproj new file mode 100644 index 0000000..69fd721 --- /dev/null +++ b/AnkiStatic/AnkiStatic.fsproj @@ -0,0 +1,34 @@ + + + + Exe + net7.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/AnkiStatic/Base91.fs b/AnkiStatic/Base91.fs new file mode 100644 index 0000000..f8bdd6e --- /dev/null +++ b/AnkiStatic/Base91.fs @@ -0,0 +1,24 @@ +namespace AnkiStatic + +open System.Text + +[] +module Base91 = + + // Replicating the Anki algorithm + let private chars = + "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" + + "0123456789" + + "!#$%&()*+,-./:;<=>?@[]^_`{|}~" + + let toString (input : uint64) : string = + let output = StringBuilder () + let mutable input = input + + while input > 0uL do + let modded = int (input % (uint64 chars.Length)) + let div = input / (uint64 chars.Length) + input <- div + output.Append chars.[modded] |> ignore + + output.ToString () diff --git a/AnkiStatic/Domain/Card.fs b/AnkiStatic/Domain/Card.fs new file mode 100644 index 0000000..d17d739 --- /dev/null +++ b/AnkiStatic/Domain/Card.fs @@ -0,0 +1,99 @@ +namespace AnkiStatic + +open System + +[] +type CardType = + | New + | Learning + | Review + | Relearning + + member this.ToInteger () = + match this with + | CardType.New -> 0 + | CardType.Learning -> 1 + | CardType.Review -> 2 + | CardType.Relearning -> 3 + +[] +type Queue = + | UserBuried + | SchedulerBuried + | Buried + | Suspended + | New + | Learning + | Review + | InLearning + | Preview + + member this.ToInteger () = + match this with + | Queue.UserBuried -> -3 + // Yes, there's an overlap. The two scheduling algorithms + // interpret -2 in a slightly different sense. + | Queue.SchedulerBuried + | Queue.Buried -> -2 + | Queue.Suspended -> -1 + | Queue.New -> 0 + | Queue.Learning -> 1 + | Queue.Review -> 2 + | Queue.InLearning -> 3 + | Queue.Preview -> 4 + +[] +type Interval = + | Seconds of int + | Days of int + | Unset + + member this.ToInteger () = + match this with + | Interval.Unset -> 0 + | Interval.Days d -> d + | Interval.Seconds s -> -s + +/// Ease of 1000 means "no bias". +/// Ease of 2500 means "this is 2.5x easier", so intervals get 2.5xed. +[] +type ease + +/// We don't model cards in a filtered deck. +type Card<'Note, 'Deck> = + { + CreationDate : DateTimeOffset + NotesId : 'Note + DeckId : 'Deck + Ordinal : int + ModificationDate : DateTimeOffset + UpdateSequenceNumber : int + Type : CardType + Queue : Queue + Due : int + Interval : Interval + EaseFactor : int + NumberOfReviews : int + NumberOfLapses : int + Left : int + OriginalDue : int + /// A client-defined extra bitmask. + Flags : int + /// Currently unused. + Data : string + } + +[] +type NewCardDistribution = + /// See new cards mixed in with reviews of old cards + | Distribute + /// See new cards after reviewing old cards + | Last + /// See new cards before reviewing old cards + | First + + member this.ToInteger () = + match this with + | NewCardDistribution.Distribute -> 0 + | NewCardDistribution.Last -> 1 + | NewCardDistribution.First -> 2 diff --git a/AnkiStatic/Domain/Collection.fs b/AnkiStatic/Domain/Collection.fs new file mode 100644 index 0000000..86da1e6 --- /dev/null +++ b/AnkiStatic/Domain/Collection.fs @@ -0,0 +1,53 @@ +namespace AnkiStatic + +open System + +type Collection<'Model, 'Deck> = + { + CreationDate : DateTimeOffset + LastModified : DateTimeOffset + LastSchemaModification : DateTimeOffset + Version : int + /// Apparently unused and always 0 + Dirty : int + UpdateSequenceNumber : int + LastSync : DateTimeOffset + Configuration : CollectionConfiguration<'Model, 'Deck> + Models : Map> + Decks : Map + DeckConfigurations : Map + Tags : string + } + +[] +module Collection = + + let getJsonDeckString (col : Collection) : string = + col.Decks + |> Map.toSeq + |> Seq.map (fun (dto, deck) -> + let timestamp = dto.ToUnixTimeMilliseconds () + Deck.toJson timestamp None deck |> sprintf "\"%i\": %s" timestamp + ) + |> String.concat "," + |> sprintf "{%s}" + + let getDeckConfigurationString (col : Collection) : string = + col.DeckConfigurations + |> Map.toSeq + |> Seq.map (fun (dto, conf) -> + let timestamp = dto.ToUnixTimeMilliseconds () + DeckConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp + ) + |> String.concat "," + |> sprintf "{%s}" + + let getJsonModelString (col : Collection) : string = + col.Models + |> Map.toSeq + |> Seq.map (fun (dto, conf) -> + let timestamp = dto.ToUnixTimeMilliseconds () + ModelConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp + ) + |> String.concat "," + |> sprintf "{%s}" diff --git a/AnkiStatic/Domain/CollectionConfiguration.fs b/AnkiStatic/Domain/CollectionConfiguration.fs new file mode 100644 index 0000000..796b421 --- /dev/null +++ b/AnkiStatic/Domain/CollectionConfiguration.fs @@ -0,0 +1,50 @@ +namespace AnkiStatic + +open System +open System.Text.Json + +type CollectionConfiguration<'Model, 'Deck> = + { + CurrentDeck : 'Deck option + ActiveDecks : 'Deck list + NewSpread : NewCardDistribution + CollapseTime : int + TimeLimit : TimeSpan + EstimateTimes : bool + ShowDueCounts : bool + CurrentModel : 'Model + NextPosition : int + /// This has some specifically allowed values, but :shrug: + SortType : string + SortBackwards : bool + /// Value of "when adding, default to current deck" + AddToCurrent : bool + } + +[] +module CollectionConfiguration = + let toJsonString (this : CollectionConfiguration) : string = + let currentDeckString = + match this.CurrentDeck with + | None -> "" + | Some d -> sprintf "\"curDeck\": %i," (d.ToUnixTimeMilliseconds ()) + + let activeDecks = + this.ActiveDecks + |> List.map (fun dto -> dto.ToUnixTimeSeconds().ToString ()) + |> String.concat "," + + $"""{{ + "nextPos": %i{this.NextPosition}, + "estTimes": %b{this.EstimateTimes}, + "activeDecks": [%s{activeDecks}], + "sortType": %s{JsonSerializer.Serialize this.SortType}, + "timeLim": %i{int this.TimeLimit.TotalSeconds}, + "sortBackwards": %b{this.SortBackwards}, + "addToCur": %b{this.AddToCurrent}, + %s{currentDeckString} + "newSpread": %i{this.NewSpread.ToInteger ()}, + "dueCounts": %b{this.ShowDueCounts}, + "curModel": "%i{this.CurrentModel.ToUnixTimeMilliseconds ()}", + "collapseTime": %i{this.CollapseTime} +}}""" diff --git a/AnkiStatic/Domain/Deck.fs b/AnkiStatic/Domain/Deck.fs new file mode 100644 index 0000000..13439b8 --- /dev/null +++ b/AnkiStatic/Domain/Deck.fs @@ -0,0 +1,58 @@ +namespace AnkiStatic + +open System +open System.Text.Json + +type Deck = + { + // We'll assume newToday, revToday, lrnToday, timeToday are all [0,0] + Name : string + ExtendedReviewLimit : int option + ExtendedNewCardLimit : int option + UpdateSequenceNumber : int + Collapsed : bool + BrowserCollapsed : bool + Description : string + LastModified : DateTimeOffset + } + +[] +module Deck = + let toJson (id : int64) (model : DateTimeOffset option) (this : Deck) : string = + let extendRev = + match this.ExtendedReviewLimit with + | None -> "" + | Some rev -> sprintf "\"extendRev\": %i," rev + + let extendNew = + match this.ExtendedNewCardLimit with + | None -> "" + | Some lim -> sprintf "\"extendNew\": %i," lim + + let model = + match model with + | None -> "" + | Some model -> model.ToUnixTimeMilliseconds () |> sprintf "\"mod\": %i," + + // TODO: what is `conf`? + $"""{{ + "name": %s{JsonSerializer.Serialize this.Name}, + "desc": %s{JsonSerializer.Serialize this.Description}, + %s{extendRev} + "usn": %i{this.UpdateSequenceNumber}, + "collapsed": %b{this.Collapsed}, + "newToday": [0,0], + "timeToday": [0,0], + "revToday": [0,0], + "lrnToday": [0,0], + "dyn": 0, + %s{model} + %s{extendNew} + "conf": 1, + "id": %i{id}, + "mod": %i{this.LastModified.ToUnixTimeSeconds ()} +}}""" + + +[] +type deck diff --git a/AnkiStatic/Domain/DeckConfiguration.fs b/AnkiStatic/Domain/DeckConfiguration.fs new file mode 100644 index 0000000..dc6681a --- /dev/null +++ b/AnkiStatic/Domain/DeckConfiguration.fs @@ -0,0 +1,130 @@ +namespace AnkiStatic + +open System +open System.Text.Json + +[] +type LeechAction = + | Suspend + | Mark + + member this.ToInteger () = + match this with + | LeechAction.Suspend -> 0 + | LeechAction.Mark -> 1 + +type LapseConfiguration = + { + Delays : int list + LeechAction : LeechAction + LeechFails : int + MinInterval : int + Multiplier : float + } + + static member toJson (this : LapseConfiguration) : string = + let delays = + this.Delays + |> Seq.map (fun (i : int) -> i.ToString ()) + |> String.concat "," + |> sprintf "[%s]" + + let mult = + if this.Multiplier <> 0.0 then + failwith "can't yet handle this" + else + "0" + + $"""{{ + "leechFails": %i{this.LeechFails}, + "minInt": %i{this.MinInterval}, + "delays": %s{delays}, + "leechAction": %i{this.LeechAction.ToInteger ()}, + "mult": %s{mult} +}}""" + +type IntervalConfiguration = + { + Good : int + Easy : int + Unused : int + } + +[] +type NewCardOrder = + | Random + | Due + + member this.ToInteger () = + match this with + | NewCardOrder.Random -> 0 + | NewCardOrder.Due -> 1 + +type NewCardConfiguration = + { + Bury : bool + Delays : int list + InitialEase : int + Intervals : IntervalConfiguration + Order : NewCardOrder + MaxNewPerDay : int + /// Apparently unused; leave this as `true` + Separate : bool + } + + static member toJson (this : NewCardConfiguration) : string = + let ints = + [ this.Intervals.Good ; this.Intervals.Easy ; this.Intervals.Unused ] + |> Seq.map (fun (s : int) -> s.ToString ()) + |> String.concat "," + |> sprintf "[%s]" + + let delays = + this.Delays + |> Seq.map (fun (s : int) -> s.ToString ()) + |> String.concat "," + |> sprintf "[%s]" + + $"""{{ + "perDay": %i{this.MaxNewPerDay}, + "delays": %s{delays}, + "separate": %b{this.Separate}, + "ints": %s{ints}, + "initialFactor": %i{this.InitialEase}, + "order": %i{this.Order.ToInteger ()} +}}""" + +type DeckConfiguration = + { + AutoPlay : bool + Lapse : LapseConfiguration + MaxTaken : TimeSpan + LastModified : DateTimeOffset + Name : string + New : NewCardConfiguration + ReplayQuestionAudioWithAnswer : bool + Review : ReviewConfiguration + ShowTimer : bool + UpdateSequenceNumber : int + } + +[] +module DeckConfiguration = + + let toJson (id : int64) (conf : DeckConfiguration) : string = + $"""{{ + "name": {JsonSerializer.Serialize conf.Name}, + "replayq": %b{conf.ReplayQuestionAudioWithAnswer}, + "lapse": %s{LapseConfiguration.toJson conf.Lapse}, + "rev": %s{ReviewConfiguration.toJson conf.Review}, + "timer": %i{if conf.ShowTimer then 1 else 0}, + "maxTaken": %i{int conf.MaxTaken.TotalSeconds}, + "usn": %i{conf.UpdateSequenceNumber}, + "new": %s{NewCardConfiguration.toJson conf.New}, + "mod": %i{conf.LastModified.ToUnixTimeMilliseconds ()}, + "id": %i{id}, + "autoplay": %b{conf.AutoPlay} +}}""" + +[] +type deckOption diff --git a/AnkiStatic/Domain/Grave.fs b/AnkiStatic/Domain/Grave.fs new file mode 100644 index 0000000..5c2c72c --- /dev/null +++ b/AnkiStatic/Domain/Grave.fs @@ -0,0 +1,19 @@ +namespace AnkiStatic + +type GraveType = + | Card + | Note + | Deck + + member this.ToInteger () = + match this with + | GraveType.Card -> 0 + | GraveType.Note -> 1 + | GraveType.Deck -> 2 + +type Grave = + { + UpdateSequenceNumber : int + ObjectId : int + Type : GraveType + } diff --git a/AnkiStatic/Domain/Model.fs b/AnkiStatic/Domain/Model.fs new file mode 100644 index 0000000..b82dcca --- /dev/null +++ b/AnkiStatic/Domain/Model.fs @@ -0,0 +1,143 @@ +namespace AnkiStatic + +open System +open System.Text.Json + +type CardTemplate<'Deck> = + { + AnswerFormat : string + BrowserAnswerFormat : string + BrowserQuestionFormat : string + DeckOverride : 'Deck option + Name : string + Ord : int + QuestionFormat : string + } + +[] +module CardTemplate = + let toJson (this : CardTemplate) : string = + let did = + match this.DeckOverride with + | None -> "null" + | Some did -> sprintf "%i" (did.ToUnixTimeMilliseconds ()) + + $"""{{ + "afmt": %s{JsonSerializer.Serialize this.AnswerFormat}, + "name": %s{JsonSerializer.Serialize this.Name}, + "qfmt": %s{JsonSerializer.Serialize this.QuestionFormat}, + "did": %s{did}, + "ord": %i{this.Ord}, + "bafmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat}, + "bqfmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat} +}}""" + +type ModelField = + { + /// E.g. "Arial" + Font : string + /// Docs suggest this is unused + Media : string list + Name : string + /// For some reason a ModelField is intended to be stored in an + /// array, but *also* tagged with its index in that array :shrug: + Ord : int + /// Whether text should display right-to-left + RightToLeft : bool + FontSize : int + Sticky : bool + } + + static member toJson (this : ModelField) : string = + let media = + this.Media + |> Seq.map JsonSerializer.Serialize + |> String.concat "," + |> sprintf "[%s]" + + $"""{{ + "size": %i{this.FontSize}, + "name": %s{JsonSerializer.Serialize this.Name}, + "media": %s{media}, + "rtl": %b{this.RightToLeft}, + "ord": %i{this.Ord}, + "font": %s{JsonSerializer.Serialize this.Font}, + "sticky": %b{this.Sticky} +}}""" + + +type ModelType = + | Standard + | Cloze + + member this.ToInteger () = + match this with + | ModelType.Standard -> 0 + | ModelType.Cloze -> 1 + +type ModelConfiguration<'Deck> = + { + Css : string + DeckId : 'Deck + Fields : ModelField list + /// String which is added to terminate LaTeX expressions + LatexPost : string + LatexPre : string + LastModification : DateTimeOffset + Name : string + // I've omitted `req` which is unused in modern clients + /// Which field the browser uses to sort by + SortField : int + /// Unused, should always be empty + Tags : string list + Templates : CardTemplate<'Deck> list + Type : ModelType + UpdateSequenceNumber : int + /// Unused, should always be empty + Version : string list + } + +[] +module ModelConfiguration = + let toJson (id : int64) (this : ModelConfiguration) : string = + let vers = + this.Version + |> Seq.map JsonSerializer.Serialize + |> String.concat "," + |> sprintf "[%s]" + + let tags = + this.Tags + |> Seq.map JsonSerializer.Serialize + |> String.concat "," + |> sprintf "[%s]" + + let flds = + this.Fields |> Seq.map ModelField.toJson |> String.concat "," |> sprintf "[%s]" + + let tmpls = + this.Templates + |> Seq.map CardTemplate.toJson + |> String.concat "," + |> sprintf "[%s]" + + $"""{{ + "vers": %s{vers}, + "name": %s{JsonSerializer.Serialize this.Name}, + "tags": %s{tags}, + "did": %i{this.DeckId.ToUnixTimeMilliseconds ()}, + "usn": %i{this.UpdateSequenceNumber}, + "flds": %s{flds}, + "sortf": %i{this.SortField}, + "tmpls": %s{tmpls}, + "latexPre": %s{JsonSerializer.Serialize this.LatexPre}, + "latexPost": %s{JsonSerializer.Serialize this.LatexPost}, + "type": %i{this.Type.ToInteger ()}, + "id": %i{id}, + "css": %s{JsonSerializer.Serialize this.Css}, + "mod": %i{this.LastModification.ToUnixTimeSeconds ()} +}}""" + +/// Identifies a type of note (e.g. "Cloze"). +[] +type model diff --git a/AnkiStatic/Domain/Note.fs b/AnkiStatic/Domain/Note.fs new file mode 100644 index 0000000..e26c47e --- /dev/null +++ b/AnkiStatic/Domain/Note.fs @@ -0,0 +1,43 @@ +namespace AnkiStatic + +open System +open System.Security.Cryptography +open System.Text + +type Note<'Model> = + { + Guid : uint64 + ModelId : 'Model + LastModified : DateTimeOffset + UpdateSequenceNumber : int + /// Serialised space-separated as a string, with a space at the start and end. + Tags : string list + /// Serialised as a string separated by the 0x1f character + Fields : string list + /// In the Sqlite table, this is an int field. + /// Sqlite is dynamically typed and accepts strings in an int field. + /// But it will sort "correctly" in the sense that integers are compared as integers + /// for the purpose of sorting in this way. + SortField : Choice + /// Unused + Flags : int + /// Unused + Data : string + } + + member this.Checksum : uint = + let fromBase256 (firstCount : int) (bytes : byte[]) : uint = + let mutable answer = 0u + + for b = 0 to firstCount - 1 do + answer <- answer * 256u + answer <- answer + uint bytes.[b] + + answer + + use sha1 = SHA1.Create () + // TODO: in the wild, this actually strips HTML first + this.Fields.[0] |> Encoding.UTF8.GetBytes |> sha1.ComputeHash |> fromBase256 4 + +[] +type note diff --git a/AnkiStatic/Domain/Review.fs b/AnkiStatic/Domain/Review.fs new file mode 100644 index 0000000..edaee9f --- /dev/null +++ b/AnkiStatic/Domain/Review.fs @@ -0,0 +1,25 @@ +namespace AnkiStatic + +open System + +type ReviewConfiguration = + { + Bury : bool + EasinessPerEasyReview : float + Fuzz : float + IntervalFactor : int + MaxInterval : TimeSpan + /// Unused; set to 1 + MinSpace : int + PerDay : int + } + + static member toJson (this : ReviewConfiguration) : string = + $"""{{ + "perDay": %i{this.PerDay}, + "ivlFct": %i{this.IntervalFactor}, + "maxIvl": %i{int this.MaxInterval.TotalDays * 100}, + "minSpace": %i{this.MinSpace}, + "ease4": %f{this.EasinessPerEasyReview}, + "fuzz": %f{this.Fuzz} +}}""" diff --git a/AnkiStatic/Examples/example-collection-conf.json b/AnkiStatic/Examples/example-collection-conf.json new file mode 100644 index 0000000..bbdfc6f --- /dev/null +++ b/AnkiStatic/Examples/example-collection-conf.json @@ -0,0 +1,16 @@ +{ + "nextPos": 1, + "estTimes": true, + "activeDecks": [ + 1 + ], + "sortType": "noteFld", + "timeLim": 0, + "sortBackwards": false, + "addToCur": true, + "curDeck": 1, + "newSpread": 0, + "dueCounts": true, + "curModel": "1373473028447", + "collapseTime": 1200 +} diff --git a/AnkiStatic/Examples/example-collection-deck-conf.json b/AnkiStatic/Examples/example-collection-deck-conf.json new file mode 100644 index 0000000..b82ccc8 --- /dev/null +++ b/AnkiStatic/Examples/example-collection-deck-conf.json @@ -0,0 +1,44 @@ +{ + "1": { + "name": "Default", + "replayq": true, + "lapse": { + "leechFails": 8, + "minInt": 1, + "delays": [ + 10 + ], + "leechAction": 0, + "mult": 0 + }, + "rev": { + "perDay": 100, + "ivlFct": 1, + "maxIvl": 36500, + "minSpace": 1, + "ease4": 1.3, + "fuzz": 0.05 + }, + "timer": 0, + "maxTaken": 60, + "usn": 0, + "new": { + "perDay": 20, + "delays": [ + 1, + 10 + ], + "separate": true, + "ints": [ + 1, + 4, + 7 + ], + "initialFactor": 2500, + "order": 1 + }, + "mod": 0, + "id": 1, + "autoplay": true + } +} diff --git a/AnkiStatic/Examples/example-collection-decks.json b/AnkiStatic/Examples/example-collection-decks.json new file mode 100644 index 0000000..6eaf5cf --- /dev/null +++ b/AnkiStatic/Examples/example-collection-decks.json @@ -0,0 +1,59 @@ +{ + "1": { + "desc": "", + "name": "Default", + "extendRev": 50, + "usn": 0, + "collapsed": false, + "newToday": [ + 0, + 0 + ], + "timeToday": [ + 0, + 0 + ], + "dyn": 0, + "extendNew": 10, + "conf": 1, + "revToday": [ + 0, + 0 + ], + "lrnToday": [ + 0, + 0 + ], + "id": 1, + "mod": 1373473028 + }, + "1369508778847": { + "name": "Analysis", + "extendRev": 50, + "usn": -1, + "collapsed": false, + "mid": "1369511891515", + "newToday": [ + 219.0, + 0 + ], + "timeToday": [ + 219.0, + 0 + ], + "dyn": 0, + "extendNew": 10, + "conf": 1, + "revToday": [ + 219.0, + 0 + ], + "lrnToday": [ + 219.0, + 0 + ], + "id": 1369508778847, + "mod": 1373402705, + "desc": "" + } +} diff --git a/AnkiStatic/Examples/example-collection-models.json b/AnkiStatic/Examples/example-collection-models.json new file mode 100644 index 0000000..d2a2f8e --- /dev/null +++ b/AnkiStatic/Examples/example-collection-models.json @@ -0,0 +1,368 @@ +{ + "1373473028441": { + "vers": [], + "name": "Basic (optional reversed card)", + "tags": [], + "did": 1, + "usn": -1, + "req": [ + [ + 0, + "all", + [ + 0 + ] + ], + [ + 1, + "all", + [ + 1, + 2 + ] + ] + ], + "flds": [ + { + "size": 20, + "name": "Front", + "media": [], + "rtl": false, + "ord": 0, + "font": "Arial", + "sticky": false + }, + { + "size": 20, + "name": "Back", + "media": [], + "rtl": false, + "ord": 1, + "font": "Arial", + "sticky": false + }, + { + "size": 20, + "name": "Add Reverse", + "media": [], + "rtl": false, + "ord": 2, + "font": "Arial", + "sticky": false + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "name": "Card 1", + "qfmt": "{{Front}}", + "did": null, + "ord": 0, + "bafmt": "", + "bqfmt": "" + }, + { + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "name": "Card 2", + "qfmt": "{{#Add Reverse}}{{Back}}{{/Add Reverse}}", + "did": null, + "ord": 1, + "bafmt": "", + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 0, + "id": "1373473028441", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "mod": 1373473028 + }, + "1373473028440": { + "vers": [], + "name": "Cloze", + "tags": [], + "did": 1, + "usn": -1, + "flds": [ + { + "size": 20, + "name": "Text", + "media": [], + "rtl": false, + "ord": 0, + "font": "Arial", + "sticky": false + }, + { + "size": 20, + "name": "Extra", + "media": [], + "rtl": false, + "ord": 1, + "font": "Arial", + "sticky": false + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "afmt": "{{cloze:Text}}
\n{{Extra}}", + "name": "Cloze", + "qfmt": "{{cloze:Text}}", + "did": null, + "ord": 0, + "bafmt": "", + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 1, + "id": "1373473028440", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.cloze {\n font-weight: bold;\n color: blue;\n}", + "mod": 1373473028 + }, + "1373473028447": { + "vers": [], + "name": "Basic", + "tags": [], + "did": 1, + "usn": -1, + "req": [ + [ + 0, + "all", + [ + 0 + ] + ] + ], + "flds": [ + { + "size": 20, + "name": "Front", + "media": [], + "rtl": false, + "ord": 0, + "font": "Arial", + "sticky": false + }, + { + "size": 20, + "name": "Back", + "media": [], + "rtl": false, + "ord": 1, + "font": "Arial", + "sticky": false + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "name": "Card 1", + "qfmt": "{{Front}}", + "did": null, + "ord": 0, + "bafmt": "", + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 0, + "id": "1373473028447", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "mod": 1373473028 + }, + "1373473028445": { + "vers": [], + "name": "Basic (and reversed card)", + "tags": [], + "did": 1, + "usn": -1, + "req": [ + [ + 0, + "all", + [ + 0 + ] + ], + [ + 1, + "all", + [ + 1 + ] + ] + ], + "flds": [ + { + "size": 20, + "name": "Front", + "media": [], + "rtl": false, + "ord": 0, + "font": "Arial", + "sticky": false + }, + { + "size": 20, + "name": "Back", + "media": [], + "rtl": false, + "ord": 1, + "font": "Arial", + "sticky": false + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "name": "Card 1", + "qfmt": "{{Front}}", + "did": null, + "ord": 0, + "bafmt": "", + "bqfmt": "" + }, + { + "afmt": "{{FrontSide}}\n\n
\n\n{{Front}}", + "name": "Card 2", + "qfmt": "{{Back}}", + "did": null, + "ord": 1, + "bafmt": "", + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 0, + "id": "1373473028445", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "mod": 1373473028 + }, + "1369511891515": { + "vers": [], + "name": "Theorem/Proof", + "tags": [], + "did": 1373192002512, + "usn": -1, + "req": [ + [ + 0, + "all", + [ + 0 + ] + ] + ], + "flds": [ + { + "name": "Theorem", + "media": [], + "sticky": false, + "rtl": false, + "ord": 0, + "font": "Arial", + "size": 20 + }, + { + "name": "Proof idea", + "media": [], + "sticky": false, + "rtl": false, + "ord": 1, + "font": "Arial", + "size": 20 + }, + { + "name": "Example", + "media": [], + "sticky": false, + "rtl": false, + "ord": 2, + "font": "Arial", + "size": 20 + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "name": "Card 1", + "qfmt": "{{Theorem}}", + "did": null, + "bafmt": "", + "afmt": "{{FrontSide}}\n\n
\n\n{{Proof idea}}", + "ord": 0, + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 0, + "id": "1369511891515", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "mod": 1373453863 + }, + "1354566092435": { + "vers": [], + "name": "Basic", + "tags": [], + "did": 1369508778847, + "usn": -1, + "req": [ + [ + 0, + "all", + [ + 0 + ] + ] + ], + "flds": [ + { + "name": "Front", + "media": [], + "sticky": false, + "rtl": false, + "ord": 0, + "font": "Arial", + "size": 20 + }, + { + "name": "Back", + "media": [], + "sticky": false, + "rtl": false, + "ord": 1, + "font": "Arial", + "size": 20 + } + ], + "sortf": 0, + "latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n", + "tmpls": [ + { + "name": "Card 1", + "qfmt": "{{Front}}", + "did": null, + "bafmt": "", + "afmt": "{{FrontSide}}\n\n
\n\n{{Back}}", + "ord": 0, + "bqfmt": "" + } + ], + "latexPost": "\\end{document}", + "type": 0, + "id": "1354566092435", + "css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n", + "mod": 1369674106 + } +} diff --git a/AnkiStatic/Program.fs b/AnkiStatic/Program.fs new file mode 100644 index 0000000..29f6fae --- /dev/null +++ b/AnkiStatic/Program.fs @@ -0,0 +1,11 @@ +namespace AnkiStatic + +open System.IO + +module Program = + [] + let main _ = + let outputFile = FileInfo "/tmp/media" + + let database = Sqlite.createEmptyPackage outputFile |> fun t -> t.Result + 0 diff --git a/AnkiStatic/SerialisedCard.fs b/AnkiStatic/SerialisedCard.fs new file mode 100644 index 0000000..1154845 --- /dev/null +++ b/AnkiStatic/SerialisedCard.fs @@ -0,0 +1,117 @@ +namespace AnkiStatic + +open System + +type SerialisedNote = + { + CreationDate : DateTimeOffset + Model : SerialisedModel + Tags : string list + ValueOfSortField : string + /// These must be in the same order as the fields of the Model. + /// TODO: type safety to get these to line up. + ValuesOfAdditionalFields : string list + } + + static member ToNote<'Model> + (guid : uint64) + (model : SerialisedModel -> 'Model) + (note : SerialisedNote) + : Note<'Model> + = + { + Guid = guid + ModelId = model note.Model + LastModified = note.CreationDate + UpdateSequenceNumber = -1 + Tags = note.Tags + Fields = note.ValueOfSortField :: note.ValuesOfAdditionalFields + SortField = Choice1Of2 note.ValueOfSortField + Flags = 0 + Data = "" + } + +[] +module SerialisedNote = + let buildCards + (cardCountSoFar : int) + (deck : SerialisedDeck) + (easeFactor : int) + (interval : Interval) + (note : SerialisedNote) + : Card list + = + let primaryCard : Card<_, _> = + { + CreationDate = note.CreationDate + NotesId = note + DeckId = deck + Interval = interval + EaseFactor = easeFactor + Ordinal = 0 + ModificationDate = note.CreationDate + TimeSpan.FromMilliseconds cardCountSoFar + UpdateSequenceNumber = -1 + Type = CardType.New + Queue = Queue.New + Due = cardCountSoFar + NumberOfReviews = 0 + NumberOfLapses = 0 + Left = 0 + Flags = 0 + Data = "" + OriginalDue = 0 + } + + let otherCards = + note.Model.AdditionalFields + |> List.mapi (fun i _field -> + { + CreationDate = note.CreationDate + NotesId = note + DeckId = deck + Interval = interval + EaseFactor = easeFactor + Ordinal = i + 1 + ModificationDate = note.CreationDate + TimeSpan.FromMilliseconds (float (cardCountSoFar + i + 1)) + UpdateSequenceNumber = -1 + Type = CardType.New + Queue = Queue.New + Due = cardCountSoFar + i + 1 + NumberOfReviews = 0 + NumberOfLapses = 0 + Left = 0 + Flags = 0 + Data = "" + OriginalDue = 0 + } + ) + + primaryCard :: otherCards + +[] +module Card = + let translate<'note, 'deck> + (noteLookup : SerialisedNote -> 'note) + (deckLookup : SerialisedDeck -> 'deck) + (serialised : Card) + : Card<'note, 'deck> + = + { + CreationDate = serialised.CreationDate + NotesId = noteLookup serialised.NotesId + DeckId = deckLookup serialised.DeckId + Ordinal = serialised.Ordinal + ModificationDate = serialised.ModificationDate + UpdateSequenceNumber = serialised.UpdateSequenceNumber + Type = serialised.Type + Queue = serialised.Queue + Due = serialised.Due + Interval = serialised.Interval + EaseFactor = serialised.EaseFactor + NumberOfReviews = serialised.NumberOfReviews + NumberOfLapses = serialised.NumberOfLapses + Left = serialised.Left + Flags = serialised.Flags + Data = serialised.Data + OriginalDue = serialised.OriginalDue + } diff --git a/AnkiStatic/SerialisedCollection.fs b/AnkiStatic/SerialisedCollection.fs new file mode 100644 index 0000000..bf9474e --- /dev/null +++ b/AnkiStatic/SerialisedCollection.fs @@ -0,0 +1,108 @@ +namespace AnkiStatic + +open System +open System.Collections.Generic + +type SerialisedCollection = + { + CreationDate : DateTimeOffset + Configuration : SerialisedCollectionConfiguration + DefaultModel : DateTimeOffset * SerialisedModel + NonDefaultModels : Map + DefaultDeck : SerialisedDeck + NonDefaultDecks : Map + DefaultDeckConfiguration : SerialisedDeckConfiguration + NonDefaultDeckConfiguration : Map + Tags : string + } + +type CollectionForSql = + { + Decks : Map + DecksInverse : SerialisedDeck -> DateTimeOffset + Models : Map> + ModelsInverse : SerialisedModel -> DateTimeOffset + Collection : Collection + } + +[] +module SerialisedCollection = + + let toSqlite (collection : SerialisedCollection) : CollectionForSql = + let decks, deckLookup = + let dict = Dictionary () + + let decks = + collection.NonDefaultDecks + |> Map.add (DateTimeOffset.FromUnixTimeMilliseconds 1) collection.DefaultDeck + |> Map.map (fun keyTimestamp deck -> + let converted = SerialisedDeck.ToDeck deck + dict.Add (deck, (keyTimestamp, converted)) + converted + ) + + let deckLookup (d : SerialisedDeck) : DateTimeOffset * Deck = + // This could look up on reference equality rather than structural equality, for speed + match dict.TryGetValue d with + | true, v -> v + | false, _ -> + failwith + $"A model declared that it was attached to a deck, but that deck was not declared in the deck list: %+A{d}" + + decks, deckLookup + + let models, modelLookup = + let dict = Dictionary () + + let models = + collection.NonDefaultModels + |> Map.add (fst collection.DefaultModel) (snd collection.DefaultModel) + |> Map.map (fun modelTimestamp v -> + let deckTimestamp, _deck = deckLookup v.Deck + dict.Add (v, modelTimestamp) + SerialisedModel.ToModel v deckTimestamp + ) + + let modelLookup (m : SerialisedModel) : DateTimeOffset = + match dict.TryGetValue m with + | true, v -> v + | false, _ -> + failwith + $"A note declared that it satisfied a model, but that model was not declared in the model list:\n\nDesired: %+A{m}\n\nAvailable: %+A{dict}" + + models, modelLookup + + let defaultDeck, _ = deckLookup collection.DefaultDeck + + let deckConfigurations = + collection.NonDefaultDeckConfiguration + |> Map.add (DateTimeOffset.FromUnixTimeMilliseconds 1) collection.DefaultDeckConfiguration + |> Map.map (fun _ -> SerialisedDeckConfiguration.ToDeckConfiguration) + + { + Decks = decks + DecksInverse = fun deck -> fst (deckLookup deck) + Models = models + ModelsInverse = modelLookup + Collection = + { + CreationDate = collection.CreationDate + LastModified = collection.CreationDate + LastSchemaModification = collection.CreationDate + Version = 11 + Dirty = 0 + UpdateSequenceNumber = -1 + LastSync = DateTimeOffset.FromUnixTimeSeconds 0 + Configuration = + collection.Configuration + |> SerialisedCollectionConfiguration.ToCollectionConfiguration + (Some defaultDeck) + // TODO: work out what it means for a deck to be a "descendant" of another + [ defaultDeck ] + (fst collection.DefaultModel) + Models = models + Decks = decks + DeckConfigurations = deckConfigurations + Tags = collection.Tags + } + } diff --git a/AnkiStatic/SerialisedDomain.fs b/AnkiStatic/SerialisedDomain.fs new file mode 100644 index 0000000..0c96e6a --- /dev/null +++ b/AnkiStatic/SerialisedDomain.fs @@ -0,0 +1,210 @@ +namespace AnkiStatic + +open System + +type SerialisedDeck = + { + Name : string + ExtendedReviewLimit : int option + ExtendedNewCardLimit : int option + Collapsed : bool + BrowserCollapsed : bool + Description : string + } + + static member ToDeck (deck : SerialisedDeck) : Deck = + { + Name = deck.Name + ExtendedReviewLimit = deck.ExtendedReviewLimit + ExtendedNewCardLimit = deck.ExtendedNewCardLimit + UpdateSequenceNumber = -1 + Collapsed = deck.Collapsed + BrowserCollapsed = deck.BrowserCollapsed + Description = deck.Description + LastModified = DateTimeOffset.FromUnixTimeSeconds 0 + } + +type SerialisedModelField = + { + /// E.g. "Arial" + Font : string + Name : string + /// Whether text should display right-to-left + RightToLeft : bool + FontSize : int + Sticky : bool + } + + static member ToModelField (counter : int) (field : SerialisedModelField) : ModelField = + { + Font = field.Font + FontSize = field.FontSize + Media = [] + Name = field.Name + Ord = counter + RightToLeft = field.RightToLeft + Sticky = field.Sticky + } + +type SerialisedCardTemplate = + { + AnswerFormat : string + BrowserAnswerFormat : string + BrowserQuestionFormat : string + Name : string + QuestionFormat : string + } + + static member ToCardTemplate<'Deck> + (deck : 'Deck option) + (counter : int) + (template : SerialisedCardTemplate) + : CardTemplate<'Deck> + = + { + AnswerFormat = template.AnswerFormat + BrowserAnswerFormat = template.BrowserAnswerFormat + BrowserQuestionFormat = template.BrowserQuestionFormat + Name = template.Name + QuestionFormat = template.QuestionFormat + DeckOverride = deck + Ord = counter + } + +type SerialisedModel = + { + Css : string + /// Any extra fields which are not the sort field + AdditionalFields : SerialisedModelField list + /// String which is added to terminate LaTeX expressions + LatexPost : string + LatexPre : string + Name : string + /// Which field the browser uses to sort by + SortField : SerialisedModelField + Templates : SerialisedCardTemplate list + Type : ModelType + Deck : SerialisedDeck + } + + static member ToModel<'Deck> (s : SerialisedModel) (deck : 'Deck) : ModelConfiguration<'Deck> = + { + Css = s.Css + DeckId = deck + Fields = + (s.SortField :: s.AdditionalFields) + |> List.mapi SerialisedModelField.ToModelField + LatexPost = s.LatexPost + LatexPre = s.LatexPre + LastModification = DateTimeOffset.FromUnixTimeSeconds 0 + Name = s.Name + SortField = 0 + Tags = [] + Templates = s.Templates |> List.mapi (SerialisedCardTemplate.ToCardTemplate None) + Type = s.Type + UpdateSequenceNumber = -1 + Version = [] + } + +type SerialisedNewCardConfiguration = + { + Delays : int list + InitialEase : int + Intervals : IntervalConfiguration + Order : NewCardOrder + MaxNewPerDay : int + } + + static member ToNewCardConfiguration (conf : SerialisedNewCardConfiguration) : NewCardConfiguration = + { + Bury = true + Delays = conf.Delays + InitialEase = conf.InitialEase + Intervals = conf.Intervals + Order = conf.Order + MaxNewPerDay = conf.MaxNewPerDay + Separate = true + } + +type SerialisedReviewConfiguration = + { + EasinessPerEasyReview : float + Fuzz : float + IntervalFactor : int + MaxInterval : TimeSpan + PerDay : int + } + + static member ToReviewConfiguration (conf : SerialisedReviewConfiguration) : ReviewConfiguration = + { + Bury = true + EasinessPerEasyReview = conf.EasinessPerEasyReview + Fuzz = conf.Fuzz + IntervalFactor = conf.IntervalFactor + MaxInterval = conf.MaxInterval + MinSpace = 1 + PerDay = conf.PerDay + } + + +type SerialisedDeckConfiguration = + { + AutoPlay : bool + Lapse : LapseConfiguration + Name : string + New : SerialisedNewCardConfiguration + ReplayQuestionAudioWithAnswer : bool + Review : SerialisedReviewConfiguration + ShowTimer : bool + MaxTimerTimeout : TimeSpan + } + + static member ToDeckConfiguration (conf : SerialisedDeckConfiguration) : DeckConfiguration = + { + AutoPlay = conf.AutoPlay + Lapse = conf.Lapse + MaxTaken = conf.MaxTimerTimeout + LastModified = DateTimeOffset.FromUnixTimeSeconds 0 + Name = conf.Name + New = conf.New |> SerialisedNewCardConfiguration.ToNewCardConfiguration + ReplayQuestionAudioWithAnswer = conf.ReplayQuestionAudioWithAnswer + Review = conf.Review |> SerialisedReviewConfiguration.ToReviewConfiguration + ShowTimer = conf.ShowTimer + UpdateSequenceNumber = -1 + } + +type SerialisedCollectionConfiguration = + { + NewSpread : NewCardDistribution + CollapseTime : int + TimeLimit : TimeSpan + EstimateTimes : bool + ShowDueCounts : bool + SortBackwards : bool + } + + static member ToCollectionConfiguration + (currentDeck : 'Deck option) + (activeDecks : 'Deck list) + (currentModel : 'Model) + (conf : SerialisedCollectionConfiguration) + : CollectionConfiguration<'Model, 'Deck> + = + { + CurrentDeck = currentDeck + ActiveDecks = activeDecks + NewSpread = conf.NewSpread + CollapseTime = conf.CollapseTime + TimeLimit = conf.TimeLimit + EstimateTimes = conf.EstimateTimes + ShowDueCounts = conf.ShowDueCounts + CurrentModel = currentModel + NextPosition = + // TODO: get this to pick up the incrementing counter + 4 + SortType = + // TODO: generalise this + "noteFld" + SortBackwards = conf.SortBackwards + AddToCurrent = true + } diff --git a/AnkiStatic/Sqlite.fs b/AnkiStatic/Sqlite.fs new file mode 100644 index 0000000..35378ff --- /dev/null +++ b/AnkiStatic/Sqlite.fs @@ -0,0 +1,352 @@ +namespace AnkiStatic + +open System +open System.Collections.Generic +open System.IO +open Microsoft.Data.Sqlite +open System.Threading.Tasks + +type EmptyPackage = private | EmptyPackage of FileInfo + +type Package = + private + | Package of FileInfo + + member this.GetFileInfo () = + match this with + | Package p -> p + +[] +module Sqlite = + let private executeCreateStatement (conn : SqliteConnection) (statement : string) = + task { + use cmd = conn.CreateCommand () + cmd.CommandText <- statement + let! result = cmd.ExecuteNonQueryAsync () + + if result <> 0 then + return failwith "unexpectedly created a row in cards creation" + } + + let createEmptyPackage (path : FileInfo) : Task = + if path.FullName.Contains ';' then + failwith "Path contained connection string metacharacter ';', so aborting." + + task { + // Connect to the SQLite database; create if not exists + let connectionString = $"Data Source=%s{path.FullName};" + use connection = new SqliteConnection (connectionString) + connection.Open () + + do! + executeCreateStatement + connection + """ +CREATE TABLE cards ( + id integer primary key, + nid integer not null, + did integer not null, + ord integer not null, + mod integer not null, + usn integer not null, + type integer not null, + queue integer not null, + due integer not null, + ivl integer not null, + factor integer not null, + reps integer not null, + lapses integer not null, + left integer not null, + odue integer not null, + odid integer not null, + flags integer not null, + data text not null +)""" + + do! + executeCreateStatement + connection + """ +CREATE TABLE col ( + id integer primary key, + crt integer not null, + mod integer not null, + scm integer not null, + ver integer not null, + dty integer not null, + usn integer not null, + ls integer not null, + conf text not null, + models text not null, + decks text not null, + dconf text not null, + tags text not null +)""" + + do! + executeCreateStatement + connection + """ +CREATE TABLE graves ( + usn integer not null, + oid integer not null, + type integer not null +)""" + + do! + executeCreateStatement + connection + """ +CREATE TABLE notes ( + id integer primary key, + guid text not null, + mid integer not null, + mod integer not null, + usn integer not null, + tags text not null, + flds text not null, + sfld integer not null, + csum integer not null, + flags integer not null, + data text not null +)""" + + do! + executeCreateStatement + connection + """ +CREATE TABLE revlog ( + id integer primary key, + cid integer not null, + usn integer not null, + ease integer not null, + ivl integer not null, + lastIvl integer not null, + factor integer not null, + time integer not null, + type integer not null +)""" + + do! + executeCreateStatement + connection + """ +CREATE INDEX ix_cards_nid on cards (nid); +CREATE INDEX ix_cards_sched on cards (did, queue, due); +CREATE INDEX ix_cards_usn on cards (usn); +CREATE INDEX ix_notes_csum on notes (csum); +CREATE INDEX ix_notes_usn on notes (usn); +CREATE INDEX ix_revlog_cid on revlog (cid); +CREATE INDEX ix_revlog_usn on revlog (usn) +""" + + return EmptyPackage path + } + + let createDecks (EmptyPackage sqliteDb) (collection : Collection) : Task = + task { + let connectionString = $"Data Source=%s{sqliteDb.FullName};" + use connection = new SqliteConnection (connectionString) + connection.Open () + + let cmd = connection.CreateCommand () + cmd.Connection <- connection + + cmd.CommandText <- + """ +INSERT INTO col +(crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags) +VALUES ($crt, $mod, $scm, $ver, $dty, $usn, $ls, $conf, $models, $decks, $dconf, $tags) +""" + + cmd.Parameters.AddWithValue ("crt", collection.CreationDate.ToUnixTimeSeconds ()) + |> ignore + + cmd.Parameters.AddWithValue ("mod", collection.LastModified.ToUnixTimeSeconds ()) + |> ignore + + cmd.Parameters.AddWithValue ("scm", collection.LastSchemaModification.ToUnixTimeSeconds ()) + |> ignore + + cmd.Parameters.AddWithValue ("ver", collection.Version) |> ignore + cmd.Parameters.AddWithValue ("dty", collection.Dirty) |> ignore + cmd.Parameters.AddWithValue ("usn", collection.UpdateSequenceNumber) |> ignore + + cmd.Parameters.AddWithValue ("ls", collection.LastSync.ToUnixTimeSeconds ()) + |> ignore + + cmd.Parameters.AddWithValue ("conf", collection.Configuration |> CollectionConfiguration.toJsonString) + |> ignore + + cmd.Parameters.AddWithValue ("models", Collection.getJsonModelString collection) + |> ignore + + cmd.Parameters.AddWithValue ("decks", Collection.getJsonDeckString collection) + |> ignore + + cmd.Parameters.AddWithValue ("dconf", Collection.getDeckConfigurationString collection) + |> ignore + + cmd.Parameters.AddWithValue ("tags", collection.Tags) |> ignore + + let! rows = cmd.ExecuteNonQueryAsync () + + if rows <> 1 then + return failwith $"Failed to insert collection row (got: %i{rows})" + else + + return Package sqliteDb + } + + /// Returns the note ID for each input note, in order. + let createNotes (Package sqliteDb) (notes : Note list) : int64 IReadOnlyList Task = + // The Anki model is *absolutely* insane and uses time of creation of a note + // as a unique key. + // Work around this madness. + + let notes = + let seenSoFar = HashSet () + let mutable maxSoFar = DateTimeOffset.MinValue + + notes + |> List.map (fun node -> + maxSoFar <- max maxSoFar node.LastModified + + if not (seenSoFar.Add node.LastModified) then + maxSoFar <- maxSoFar + TimeSpan.FromMilliseconds 1.0 + + if not (seenSoFar.Add maxSoFar) then + failwith "logic has failed me" + + { node with + LastModified = maxSoFar + } + else + node + ) + + task { + let connectionString = $"Data Source=%s{sqliteDb.FullName};" + use connection = new SqliteConnection (connectionString) + connection.Open () + + let cmd = connection.CreateCommand () + + cmd.CommandText <- + """ +INSERT INTO notes +(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data) +VALUES ($id, $guid, $mid, $mod, $usn, $tags, $flds, $sfld, $csum, $flags, $data) +""" + + cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("guid", SqliteType.Text) |> ignore + cmd.Parameters.Add ("mid", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("tags", SqliteType.Text) |> ignore + cmd.Parameters.Add ("flds", SqliteType.Text) |> ignore + cmd.Parameters.Add ("sfld", SqliteType.Text) |> ignore + cmd.Parameters.Add ("csum", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("data", SqliteType.Text) |> ignore + do! cmd.PrepareAsync () + + let result = ResizeArray () + + for note in notes do + cmd.Parameters.["id"].Value <- note.LastModified.ToUnixTimeMilliseconds () + cmd.Parameters.["guid"].Value <- note.Guid |> Base91.toString + cmd.Parameters.["mid"].Value <- note.ModelId.ToUnixTimeMilliseconds () + cmd.Parameters.["mod"].Value <- note.LastModified.ToUnixTimeSeconds () + cmd.Parameters.["usn"].Value <- note.UpdateSequenceNumber + + cmd.Parameters.["tags"].Value <- + match note.Tags with + | [] -> "" + | tags -> String.concat " " tags |> sprintf " %s " + + cmd.Parameters.["flds"].Value <- note.Fields |> String.concat "\u001f" + + cmd.Parameters.["sfld"].Value <- + match note.SortField with + | Choice1Of2 s -> s + | Choice2Of2 i -> i.ToString () + + cmd.Parameters.["csum"].Value <- note.Checksum + cmd.Parameters.["flags"].Value <- note.Flags + cmd.Parameters.["data"].Value <- note.Data + + let! count = cmd.ExecuteNonQueryAsync () + + if count <> 1 then + failwithf "failed to insert note, got count: %i" count + + let id = note.LastModified.ToUnixTimeMilliseconds () + result.Add id + + return result :> IReadOnlyList<_> + } + + let createCards (Package sqliteDb) (cards : Card list) = + task { + let connectionString = $"Data Source=%s{sqliteDb.FullName};" + use connection = new SqliteConnection (connectionString) + connection.Open () + + let cmd = connection.CreateCommand () + + cmd.CommandText <- + """ +INSERT INTO cards +(id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data) +VALUES (@id, @nid, @did, @ord, @mod, @usn, @type, @queue, @due, @ivl, @factor, @reps, @lapses, @left, @odue, @odid, @flags, @data) +""" + + cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("nid", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("did", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("ord", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("type", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("queue", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("due", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("ivl", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("factor", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("reps", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("lapses", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("left", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("odue", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("odid", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore + cmd.Parameters.Add ("data", SqliteType.Text) |> ignore + do! cmd.PrepareAsync () + + for card in cards do + cmd.Parameters.["id"].Value <- card.ModificationDate.ToUnixTimeMilliseconds () + cmd.Parameters.["nid"].Value <- card.NotesId + cmd.Parameters.["did"].Value <- card.DeckId.ToUnixTimeMilliseconds () + cmd.Parameters.["ord"].Value <- card.Ordinal + cmd.Parameters.["mod"].Value <- card.ModificationDate.ToUnixTimeSeconds () + cmd.Parameters.["usn"].Value <- card.UpdateSequenceNumber + cmd.Parameters.["type"].Value <- card.Type.ToInteger () + cmd.Parameters.["queue"].Value <- card.Queue.ToInteger () + cmd.Parameters.["due"].Value <- card.Due + cmd.Parameters.["ivl"].Value <- card.Interval.ToInteger () + cmd.Parameters.["factor"].Value <- card.EaseFactor + cmd.Parameters.["reps"].Value <- card.NumberOfReviews + cmd.Parameters.["lapses"].Value <- card.NumberOfLapses + cmd.Parameters.["left"].Value <- card.Left + cmd.Parameters.["odue"].Value <- card.OriginalDue + cmd.Parameters.["odid"].Value <- 0 + cmd.Parameters.["flags"].Value <- card.Flags + cmd.Parameters.["data"].Value <- card.Data + + let! result = cmd.ExecuteNonQueryAsync () + + if result <> 1 then + failwith $"Did not get exactly 1 row back from insertion: %i{result}" + + return () + } diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..e0f362f --- /dev/null +++ b/flake.lock @@ -0,0 +1,60 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1692799911, + "narHash": "sha256-3eihraek4qL744EvQXsK1Ha6C3CR7nnT8X2qWap4RNk=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "f9e7cf818399d17d347f847525c5a5a8032e4e44", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1694021185, + "narHash": "sha256-v5Ie83yfsiQgp4GDRZFIsbkctEynfOdNOi67vBH12XM=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "3e233330d9f88f78c75c2a164a50807e44245007", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000..5dbfe81 --- /dev/null +++ b/flake.nix @@ -0,0 +1,28 @@ +{ + inputs = { + nixpkgs.url = "github:nixos/nixpkgs"; + flake-utils = { + url = "github:numtide/flake-utils"; + }; + }; + + outputs = inputs @ { + self, + nixpkgs, + flake-utils, + ... + }: + flake-utils.lib.eachDefaultSystem (system: let + pkgs = nixpkgs.legacyPackages.${system}; + in { + devShells.default = pkgs.mkShell { + buildInputs = + [pkgs.alejandra pkgs.dotnet-sdk_7 pkgs.python3] + ++ ( + if pkgs.stdenv.isDarwin + then [pkgs.darwin.apple_sdk.frameworks.CoreServices] + else [] + ); + }; + }); +} diff --git a/hooks/pre-push b/hooks/pre-push new file mode 100755 index 0000000..4f7ce2f --- /dev/null +++ b/hooks/pre-push @@ -0,0 +1,25 @@ +#!/usr/bin/env python3 + +import subprocess + +def check_fantomas(): + result = subprocess.run(["dotnet", "tool", "run", "fantomas", "--check", "."]) + if result.returncode != 0: + print(result.stdout) + raise Exception(f"Formatting incomplete (return code: {result.returncode}). Consider running `dotnet tool run fantomas .`") + + +def check_alejandra(): + result = subprocess.run(["alejandra", "--check", "--quiet", "*.nix"]) + if result.returncode != 0: + print(result.stdout) + raise Exception(f"Formatting incomplete (return code: {result.returncode}). Consider running `alejandra *.nix`") + + +def main(): + check_fantomas() + check_alejandra() + + +if __name__ == "__main__": + main()