Initial commit

This commit is contained in:
Smaug123
2023-09-06 18:28:06 +01:00
commit ecd168d284
33 changed files with 2521 additions and 0 deletions

12
.config/dotnet-tools.json Normal file
View File

@@ -0,0 +1,12 @@
{
"version": 1,
"isRoot": true,
"tools": {
"fantomas": {
"version": "6.2.0",
"commands": [
"fantomas"
]
}
}
}

41
.editorconfig Normal file
View File

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

4
.gitattributes vendored Normal file
View File

@@ -0,0 +1,4 @@
* text=auto
*.sh text eol=lf
*.nix text eol=lf
hooks/pre-push text eol=lf

10
.gitignore vendored Normal file
View File

@@ -0,0 +1,10 @@
bin/
obj/
/packages/
riderModule.iml
/_ReSharper.Caches/
.idea/
*.user
*.DotSettings
.profile*
test.sqlite

View File

@@ -0,0 +1,10 @@
steps:
echo:
image: alpine
commands:
- echo "All required checks complete"
depends_on:
- build
skip_clone: true

15
.woodpecker/.build.yml Normal file
View File

@@ -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"

View File

@@ -0,0 +1,28 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<Compile Include="LonghandExample.fs"/>
<Compile Include="Tests.fs" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="FsUnit" Version="5.4.0"/>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.5.0"/>
<PackageReference Include="NUnit" Version="3.13.3"/>
<PackageReference Include="NUnit3TestAdapter" Version="4.4.2"/>
<PackageReference Include="NUnit.Analyzers" Version="3.6.1"/>
<PackageReference Include="coverlet.collector" Version="3.2.0"/>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AnkiStatic\AnkiStatic.fsproj"/>
</ItemGroup>
</Project>

View File

@@ -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
[<TestFixture>]
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)
[<Test>]
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<hr id=answer>\n\n{{Back}}"
BrowserAnswerFormat = ""
BrowserQuestionFormat = ""
Name = "Card 1"
QuestionFormat = "{{Front}}"
}
let backTemplate : SerialisedCardTemplate =
{
AnswerFormat = "{{FrontSide}}\n\n<hr id=answer>\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}}<br>\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<ease>
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<ease> Interval.Unset note
|> List.map (Card.translate (fun note -> noteIds.[lookupNote note]) collection.DecksInverse)
built.Length + count, iter + 1, built @ cards
)
do! Sqlite.createCards written cards
let outputFile =
Path.GetTempFileName ()
|> fun f -> Path.ChangeExtension (f, ".apkg")
|> FileInfo
use outputStream = outputFile.OpenWrite ()
use archive = new ZipArchive (outputStream, ZipArchiveMode.Create, true)
let entry = archive.CreateEntry "collection.anki2"
use entryStream = entry.Open ()
use contents = file.OpenRead ()
do! contents.CopyToAsync entryStream
Console.WriteLine $"Written: %s{outputFile.FullName}"
return ()
}

29
AnkiStatic.Test/Tests.fs Normal file
View File

@@ -0,0 +1,29 @@
namespace AnkiStatic.Test
open System
open AnkiStatic
open FsUnitTyped
open NUnit.Framework
open System.Text
open System.Security.Cryptography
[<TestFixture>]
module Tests =
[<TestCase("A continuous function on a closed bounded interval is bounded and attains its bounds.", 375454972u)>]
let ``Checksum matches`` (str : string, expected : uint32) =
let data : Note<unit> =
{
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

22
AnkiStatic.sln Normal file
View File

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

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<Compile Include="Domain\Deck.fs"/>
<Compile Include="Domain\Model.fs"/>
<Compile Include="Domain\Note.fs"/>
<Compile Include="Domain\Card.fs"/>
<Compile Include="Domain\Review.fs"/>
<Compile Include="Domain\DeckConfiguration.fs"/>
<Compile Include="Domain\CollectionConfiguration.fs"/>
<Compile Include="Domain\Collection.fs"/>
<Compile Include="Domain\Grave.fs"/>
<Compile Include="SerialisedDomain.fs"/>
<Compile Include="SerialisedCard.fs" />
<Compile Include="SerialisedCollection.fs"/>
<Compile Include="Base91.fs" />
<Compile Include="Sqlite.fs"/>
<Compile Include="Program.fs"/>
<Content Include="Examples\example-collection-conf.json"/>
<Content Include="Examples\example-collection-models.json"/>
<Content Include="Examples\example-collection-decks.json"/>
<Content Include="Examples\example-collection-deck-conf.json"/>
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.10"/>
</ItemGroup>
</Project>

24
AnkiStatic/Base91.fs Normal file
View File

@@ -0,0 +1,24 @@
namespace AnkiStatic
open System.Text
[<RequireQualifiedAccess>]
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 ()

99
AnkiStatic/Domain/Card.fs Normal file
View File

@@ -0,0 +1,99 @@
namespace AnkiStatic
open System
[<RequireQualifiedAccess>]
type CardType =
| New
| Learning
| Review
| Relearning
member this.ToInteger () =
match this with
| CardType.New -> 0
| CardType.Learning -> 1
| CardType.Review -> 2
| CardType.Relearning -> 3
[<RequireQualifiedAccess>]
type Queue =
| UserBuried
| SchedulerBuried
| Buried
| Suspended
| New
| Learning
| Review
| InLearning
| Preview
member this.ToInteger () =
match this with
| Queue.UserBuried -> -3
// Yes, there's an overlap. The two scheduling algorithms
// interpret -2 in a slightly different sense.
| Queue.SchedulerBuried
| Queue.Buried -> -2
| Queue.Suspended -> -1
| Queue.New -> 0
| Queue.Learning -> 1
| Queue.Review -> 2
| Queue.InLearning -> 3
| Queue.Preview -> 4
[<RequireQualifiedAccess>]
type Interval =
| Seconds of int
| Days of int
| Unset
member this.ToInteger () =
match this with
| Interval.Unset -> 0
| Interval.Days d -> d
| Interval.Seconds s -> -s
/// Ease of 1000 means "no bias".
/// Ease of 2500 means "this is 2.5x easier", so intervals get 2.5xed.
[<Measure>]
type ease
/// We don't model cards in a filtered deck.
type Card<'Note, 'Deck> =
{
CreationDate : DateTimeOffset
NotesId : 'Note
DeckId : 'Deck
Ordinal : int
ModificationDate : DateTimeOffset
UpdateSequenceNumber : int
Type : CardType
Queue : Queue
Due : int
Interval : Interval
EaseFactor : int<ease>
NumberOfReviews : int
NumberOfLapses : int
Left : int
OriginalDue : int
/// A client-defined extra bitmask.
Flags : int
/// Currently unused.
Data : string
}
[<RequireQualifiedAccess>]
type NewCardDistribution =
/// See new cards mixed in with reviews of old cards
| Distribute
/// See new cards after reviewing old cards
| Last
/// See new cards before reviewing old cards
| First
member this.ToInteger () =
match this with
| NewCardDistribution.Distribute -> 0
| NewCardDistribution.Last -> 1
| NewCardDistribution.First -> 2

View File

@@ -0,0 +1,53 @@
namespace AnkiStatic
open System
type Collection<'Model, 'Deck> =
{
CreationDate : DateTimeOffset
LastModified : DateTimeOffset
LastSchemaModification : DateTimeOffset
Version : int
/// Apparently unused and always 0
Dirty : int
UpdateSequenceNumber : int
LastSync : DateTimeOffset
Configuration : CollectionConfiguration<'Model, 'Deck>
Models : Map<DateTimeOffset, ModelConfiguration<'Deck>>
Decks : Map<DateTimeOffset, Deck>
DeckConfigurations : Map<DateTimeOffset, DeckConfiguration>
Tags : string
}
[<RequireQualifiedAccess>]
module Collection =
let getJsonDeckString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
col.Decks
|> Map.toSeq
|> Seq.map (fun (dto, deck) ->
let timestamp = dto.ToUnixTimeMilliseconds ()
Deck.toJson timestamp None deck |> sprintf "\"%i\": %s" timestamp
)
|> String.concat ","
|> sprintf "{%s}"
let getDeckConfigurationString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
col.DeckConfigurations
|> Map.toSeq
|> Seq.map (fun (dto, conf) ->
let timestamp = dto.ToUnixTimeMilliseconds ()
DeckConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp
)
|> String.concat ","
|> sprintf "{%s}"
let getJsonModelString (col : Collection<DateTimeOffset, DateTimeOffset>) : string =
col.Models
|> Map.toSeq
|> Seq.map (fun (dto, conf) ->
let timestamp = dto.ToUnixTimeMilliseconds ()
ModelConfiguration.toJson timestamp conf |> sprintf "\"%i\": %s" timestamp
)
|> String.concat ","
|> sprintf "{%s}"

View File

@@ -0,0 +1,50 @@
namespace AnkiStatic
open System
open System.Text.Json
type CollectionConfiguration<'Model, 'Deck> =
{
CurrentDeck : 'Deck option
ActiveDecks : 'Deck list
NewSpread : NewCardDistribution
CollapseTime : int
TimeLimit : TimeSpan
EstimateTimes : bool
ShowDueCounts : bool
CurrentModel : 'Model
NextPosition : int
/// This has some specifically allowed values, but :shrug:
SortType : string
SortBackwards : bool
/// Value of "when adding, default to current deck"
AddToCurrent : bool
}
[<RequireQualifiedAccess>]
module CollectionConfiguration =
let toJsonString (this : CollectionConfiguration<DateTimeOffset, DateTimeOffset>) : string =
let currentDeckString =
match this.CurrentDeck with
| None -> ""
| Some d -> sprintf "\"curDeck\": %i," (d.ToUnixTimeMilliseconds ())
let activeDecks =
this.ActiveDecks
|> List.map (fun dto -> dto.ToUnixTimeSeconds().ToString ())
|> String.concat ","
$"""{{
"nextPos": %i{this.NextPosition},
"estTimes": %b{this.EstimateTimes},
"activeDecks": [%s{activeDecks}],
"sortType": %s{JsonSerializer.Serialize this.SortType},
"timeLim": %i{int this.TimeLimit.TotalSeconds},
"sortBackwards": %b{this.SortBackwards},
"addToCur": %b{this.AddToCurrent},
%s{currentDeckString}
"newSpread": %i{this.NewSpread.ToInteger ()},
"dueCounts": %b{this.ShowDueCounts},
"curModel": "%i{this.CurrentModel.ToUnixTimeMilliseconds ()}",
"collapseTime": %i{this.CollapseTime}
}}"""

58
AnkiStatic/Domain/Deck.fs Normal file
View File

@@ -0,0 +1,58 @@
namespace AnkiStatic
open System
open System.Text.Json
type Deck =
{
// We'll assume newToday, revToday, lrnToday, timeToday are all [0,0]
Name : string
ExtendedReviewLimit : int option
ExtendedNewCardLimit : int option
UpdateSequenceNumber : int
Collapsed : bool
BrowserCollapsed : bool
Description : string
LastModified : DateTimeOffset
}
[<RequireQualifiedAccess>]
module Deck =
let toJson (id : int64) (model : DateTimeOffset option) (this : Deck) : string =
let extendRev =
match this.ExtendedReviewLimit with
| None -> ""
| Some rev -> sprintf "\"extendRev\": %i," rev
let extendNew =
match this.ExtendedNewCardLimit with
| None -> ""
| Some lim -> sprintf "\"extendNew\": %i," lim
let model =
match model with
| None -> ""
| Some model -> model.ToUnixTimeMilliseconds () |> sprintf "\"mod\": %i,"
// TODO: what is `conf`?
$"""{{
"name": %s{JsonSerializer.Serialize this.Name},
"desc": %s{JsonSerializer.Serialize this.Description},
%s{extendRev}
"usn": %i{this.UpdateSequenceNumber},
"collapsed": %b{this.Collapsed},
"newToday": [0,0],
"timeToday": [0,0],
"revToday": [0,0],
"lrnToday": [0,0],
"dyn": 0,
%s{model}
%s{extendNew}
"conf": 1,
"id": %i{id},
"mod": %i{this.LastModified.ToUnixTimeSeconds ()}
}}"""
[<Measure>]
type deck

View File

@@ -0,0 +1,130 @@
namespace AnkiStatic
open System
open System.Text.Json
[<RequireQualifiedAccess>]
type LeechAction =
| Suspend
| Mark
member this.ToInteger () =
match this with
| LeechAction.Suspend -> 0
| LeechAction.Mark -> 1
type LapseConfiguration =
{
Delays : int list
LeechAction : LeechAction
LeechFails : int
MinInterval : int
Multiplier : float
}
static member toJson (this : LapseConfiguration) : string =
let delays =
this.Delays
|> Seq.map (fun (i : int) -> i.ToString ())
|> String.concat ","
|> sprintf "[%s]"
let mult =
if this.Multiplier <> 0.0 then
failwith "can't yet handle this"
else
"0"
$"""{{
"leechFails": %i{this.LeechFails},
"minInt": %i{this.MinInterval},
"delays": %s{delays},
"leechAction": %i{this.LeechAction.ToInteger ()},
"mult": %s{mult}
}}"""
type IntervalConfiguration =
{
Good : int
Easy : int
Unused : int
}
[<RequireQualifiedAccess>]
type NewCardOrder =
| Random
| Due
member this.ToInteger () =
match this with
| NewCardOrder.Random -> 0
| NewCardOrder.Due -> 1
type NewCardConfiguration =
{
Bury : bool
Delays : int list
InitialEase : int<ease>
Intervals : IntervalConfiguration
Order : NewCardOrder
MaxNewPerDay : int
/// Apparently unused; leave this as `true`
Separate : bool
}
static member toJson (this : NewCardConfiguration) : string =
let ints =
[ this.Intervals.Good ; this.Intervals.Easy ; this.Intervals.Unused ]
|> Seq.map (fun (s : int) -> s.ToString ())
|> String.concat ","
|> sprintf "[%s]"
let delays =
this.Delays
|> Seq.map (fun (s : int) -> s.ToString ())
|> String.concat ","
|> sprintf "[%s]"
$"""{{
"perDay": %i{this.MaxNewPerDay},
"delays": %s{delays},
"separate": %b{this.Separate},
"ints": %s{ints},
"initialFactor": %i{this.InitialEase},
"order": %i{this.Order.ToInteger ()}
}}"""
type DeckConfiguration =
{
AutoPlay : bool
Lapse : LapseConfiguration
MaxTaken : TimeSpan
LastModified : DateTimeOffset
Name : string
New : NewCardConfiguration
ReplayQuestionAudioWithAnswer : bool
Review : ReviewConfiguration
ShowTimer : bool
UpdateSequenceNumber : int
}
[<RequireQualifiedAccess>]
module DeckConfiguration =
let toJson (id : int64) (conf : DeckConfiguration) : string =
$"""{{
"name": {JsonSerializer.Serialize conf.Name},
"replayq": %b{conf.ReplayQuestionAudioWithAnswer},
"lapse": %s{LapseConfiguration.toJson conf.Lapse},
"rev": %s{ReviewConfiguration.toJson conf.Review},
"timer": %i{if conf.ShowTimer then 1 else 0},
"maxTaken": %i{int conf.MaxTaken.TotalSeconds},
"usn": %i{conf.UpdateSequenceNumber},
"new": %s{NewCardConfiguration.toJson conf.New},
"mod": %i{conf.LastModified.ToUnixTimeMilliseconds ()},
"id": %i{id},
"autoplay": %b{conf.AutoPlay}
}}"""
[<Measure>]
type deckOption

View File

@@ -0,0 +1,19 @@
namespace AnkiStatic
type GraveType =
| Card
| Note
| Deck
member this.ToInteger () =
match this with
| GraveType.Card -> 0
| GraveType.Note -> 1
| GraveType.Deck -> 2
type Grave =
{
UpdateSequenceNumber : int
ObjectId : int
Type : GraveType
}

143
AnkiStatic/Domain/Model.fs Normal file
View File

@@ -0,0 +1,143 @@
namespace AnkiStatic
open System
open System.Text.Json
type CardTemplate<'Deck> =
{
AnswerFormat : string
BrowserAnswerFormat : string
BrowserQuestionFormat : string
DeckOverride : 'Deck option
Name : string
Ord : int
QuestionFormat : string
}
[<RequireQualifiedAccess>]
module CardTemplate =
let toJson (this : CardTemplate<DateTimeOffset>) : string =
let did =
match this.DeckOverride with
| None -> "null"
| Some did -> sprintf "%i" (did.ToUnixTimeMilliseconds ())
$"""{{
"afmt": %s{JsonSerializer.Serialize this.AnswerFormat},
"name": %s{JsonSerializer.Serialize this.Name},
"qfmt": %s{JsonSerializer.Serialize this.QuestionFormat},
"did": %s{did},
"ord": %i{this.Ord},
"bafmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat},
"bqfmt": %s{JsonSerializer.Serialize this.BrowserAnswerFormat}
}}"""
type ModelField =
{
/// E.g. "Arial"
Font : string
/// Docs suggest this is unused
Media : string list
Name : string
/// For some reason a ModelField is intended to be stored in an
/// array, but *also* tagged with its index in that array :shrug:
Ord : int
/// Whether text should display right-to-left
RightToLeft : bool
FontSize : int
Sticky : bool
}
static member toJson (this : ModelField) : string =
let media =
this.Media
|> Seq.map JsonSerializer.Serialize
|> String.concat ","
|> sprintf "[%s]"
$"""{{
"size": %i{this.FontSize},
"name": %s{JsonSerializer.Serialize this.Name},
"media": %s{media},
"rtl": %b{this.RightToLeft},
"ord": %i{this.Ord},
"font": %s{JsonSerializer.Serialize this.Font},
"sticky": %b{this.Sticky}
}}"""
type ModelType =
| Standard
| Cloze
member this.ToInteger () =
match this with
| ModelType.Standard -> 0
| ModelType.Cloze -> 1
type ModelConfiguration<'Deck> =
{
Css : string
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
}
[<RequireQualifiedAccess>]
module ModelConfiguration =
let toJson (id : int64) (this : ModelConfiguration<DateTimeOffset>) : string =
let vers =
this.Version
|> Seq.map JsonSerializer.Serialize
|> String.concat ","
|> sprintf "[%s]"
let tags =
this.Tags
|> Seq.map JsonSerializer.Serialize
|> String.concat ","
|> sprintf "[%s]"
let flds =
this.Fields |> Seq.map ModelField.toJson |> String.concat "," |> sprintf "[%s]"
let tmpls =
this.Templates
|> Seq.map CardTemplate.toJson
|> String.concat ","
|> sprintf "[%s]"
$"""{{
"vers": %s{vers},
"name": %s{JsonSerializer.Serialize this.Name},
"tags": %s{tags},
"did": %i{this.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").
[<Measure>]
type model

43
AnkiStatic/Domain/Note.fs Normal file
View File

@@ -0,0 +1,43 @@
namespace AnkiStatic
open System
open System.Security.Cryptography
open System.Text
type Note<'Model> =
{
Guid : uint64
ModelId : 'Model
LastModified : DateTimeOffset
UpdateSequenceNumber : int
/// Serialised space-separated as a string, with a space at the start and end.
Tags : string list
/// Serialised as a string separated by the 0x1f character
Fields : string list
/// In the Sqlite table, this is an int field.
/// Sqlite is dynamically typed and accepts strings in an int field.
/// But it will sort "correctly" in the sense that integers are compared as integers
/// for the purpose of sorting in this way.
SortField : Choice<string, int>
/// Unused
Flags : int
/// Unused
Data : string
}
member this.Checksum : uint =
let fromBase256 (firstCount : int) (bytes : byte[]) : uint =
let mutable answer = 0u
for b = 0 to firstCount - 1 do
answer <- answer * 256u
answer <- answer + uint bytes.[b]
answer
use sha1 = SHA1.Create ()
// TODO: in the wild, this actually strips HTML first
this.Fields.[0] |> Encoding.UTF8.GetBytes |> sha1.ComputeHash |> fromBase256 4
[<Measure>]
type note

View File

@@ -0,0 +1,25 @@
namespace AnkiStatic
open System
type ReviewConfiguration =
{
Bury : bool
EasinessPerEasyReview : float
Fuzz : float
IntervalFactor : int
MaxInterval : TimeSpan
/// Unused; set to 1
MinSpace : int
PerDay : int
}
static member toJson (this : ReviewConfiguration) : string =
$"""{{
"perDay": %i{this.PerDay},
"ivlFct": %i{this.IntervalFactor},
"maxIvl": %i{int this.MaxInterval.TotalDays * 100},
"minSpace": %i{this.MinSpace},
"ease4": %f{this.EasinessPerEasyReview},
"fuzz": %f{this.Fuzz}
}}"""

View File

@@ -0,0 +1,16 @@
{
"nextPos": 1,
"estTimes": true,
"activeDecks": [
1
],
"sortType": "noteFld",
"timeLim": 0,
"sortBackwards": false,
"addToCur": true,
"curDeck": 1,
"newSpread": 0,
"dueCounts": true,
"curModel": "1373473028447",
"collapseTime": 1200
}

View File

@@ -0,0 +1,44 @@
{
"1": {
"name": "Default",
"replayq": true,
"lapse": {
"leechFails": 8,
"minInt": 1,
"delays": [
10
],
"leechAction": 0,
"mult": 0
},
"rev": {
"perDay": 100,
"ivlFct": 1,
"maxIvl": 36500,
"minSpace": 1,
"ease4": 1.3,
"fuzz": 0.05
},
"timer": 0,
"maxTaken": 60,
"usn": 0,
"new": {
"perDay": 20,
"delays": [
1,
10
],
"separate": true,
"ints": [
1,
4,
7
],
"initialFactor": 2500,
"order": 1
},
"mod": 0,
"id": 1,
"autoplay": true
}
}

View File

@@ -0,0 +1,59 @@
{
"1": {
"desc": "",
"name": "Default",
"extendRev": 50,
"usn": 0,
"collapsed": false,
"newToday": [
0,
0
],
"timeToday": [
0,
0
],
"dyn": 0,
"extendNew": 10,
"conf": 1,
"revToday": [
0,
0
],
"lrnToday": [
0,
0
],
"id": 1,
"mod": 1373473028
},
"1369508778847": {
"name": "Analysis",
"extendRev": 50,
"usn": -1,
"collapsed": false,
"mid": "1369511891515",
"newToday": [
219.0,
0
],
"timeToday": [
219.0,
0
],
"dyn": 0,
"extendNew": 10,
"conf": 1,
"revToday": [
219.0,
0
],
"lrnToday": [
219.0,
0
],
"id": 1369508778847,
"mod": 1373402705,
"desc": ""
}
}

View File

@@ -0,0 +1,368 @@
{
"1373473028441": {
"vers": [],
"name": "Basic (optional reversed card)",
"tags": [],
"did": 1,
"usn": -1,
"req": [
[
0,
"all",
[
0
]
],
[
1,
"all",
[
1,
2
]
]
],
"flds": [
{
"size": 20,
"name": "Front",
"media": [],
"rtl": false,
"ord": 0,
"font": "Arial",
"sticky": false
},
{
"size": 20,
"name": "Back",
"media": [],
"rtl": false,
"ord": 1,
"font": "Arial",
"sticky": false
},
{
"size": 20,
"name": "Add Reverse",
"media": [],
"rtl": false,
"ord": 2,
"font": "Arial",
"sticky": false
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
"name": "Card 1",
"qfmt": "{{Front}}",
"did": null,
"ord": 0,
"bafmt": "",
"bqfmt": ""
},
{
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
"name": "Card 2",
"qfmt": "{{#Add Reverse}}{{Back}}{{/Add Reverse}}",
"did": null,
"ord": 1,
"bafmt": "",
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 0,
"id": "1373473028441",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
"mod": 1373473028
},
"1373473028440": {
"vers": [],
"name": "Cloze",
"tags": [],
"did": 1,
"usn": -1,
"flds": [
{
"size": 20,
"name": "Text",
"media": [],
"rtl": false,
"ord": 0,
"font": "Arial",
"sticky": false
},
{
"size": 20,
"name": "Extra",
"media": [],
"rtl": false,
"ord": 1,
"font": "Arial",
"sticky": false
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"afmt": "{{cloze:Text}}<br>\n{{Extra}}",
"name": "Cloze",
"qfmt": "{{cloze:Text}}",
"did": null,
"ord": 0,
"bafmt": "",
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 1,
"id": "1373473028440",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.cloze {\n font-weight: bold;\n color: blue;\n}",
"mod": 1373473028
},
"1373473028447": {
"vers": [],
"name": "Basic",
"tags": [],
"did": 1,
"usn": -1,
"req": [
[
0,
"all",
[
0
]
]
],
"flds": [
{
"size": 20,
"name": "Front",
"media": [],
"rtl": false,
"ord": 0,
"font": "Arial",
"sticky": false
},
{
"size": 20,
"name": "Back",
"media": [],
"rtl": false,
"ord": 1,
"font": "Arial",
"sticky": false
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
"name": "Card 1",
"qfmt": "{{Front}}",
"did": null,
"ord": 0,
"bafmt": "",
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 0,
"id": "1373473028447",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
"mod": 1373473028
},
"1373473028445": {
"vers": [],
"name": "Basic (and reversed card)",
"tags": [],
"did": 1,
"usn": -1,
"req": [
[
0,
"all",
[
0
]
],
[
1,
"all",
[
1
]
]
],
"flds": [
{
"size": 20,
"name": "Front",
"media": [],
"rtl": false,
"ord": 0,
"font": "Arial",
"sticky": false
},
{
"size": 20,
"name": "Back",
"media": [],
"rtl": false,
"ord": 1,
"font": "Arial",
"sticky": false
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
"name": "Card 1",
"qfmt": "{{Front}}",
"did": null,
"ord": 0,
"bafmt": "",
"bqfmt": ""
},
{
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
"name": "Card 2",
"qfmt": "{{Back}}",
"did": null,
"ord": 1,
"bafmt": "",
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 0,
"id": "1373473028445",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
"mod": 1373473028
},
"1369511891515": {
"vers": [],
"name": "Theorem/Proof",
"tags": [],
"did": 1373192002512,
"usn": -1,
"req": [
[
0,
"all",
[
0
]
]
],
"flds": [
{
"name": "Theorem",
"media": [],
"sticky": false,
"rtl": false,
"ord": 0,
"font": "Arial",
"size": 20
},
{
"name": "Proof idea",
"media": [],
"sticky": false,
"rtl": false,
"ord": 1,
"font": "Arial",
"size": 20
},
{
"name": "Example",
"media": [],
"sticky": false,
"rtl": false,
"ord": 2,
"font": "Arial",
"size": 20
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"name": "Card 1",
"qfmt": "{{Theorem}}",
"did": null,
"bafmt": "",
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Proof idea}}",
"ord": 0,
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 0,
"id": "1369511891515",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
"mod": 1373453863
},
"1354566092435": {
"vers": [],
"name": "Basic",
"tags": [],
"did": 1369508778847,
"usn": -1,
"req": [
[
0,
"all",
[
0
]
]
],
"flds": [
{
"name": "Front",
"media": [],
"sticky": false,
"rtl": false,
"ord": 0,
"font": "Arial",
"size": 20
},
{
"name": "Back",
"media": [],
"sticky": false,
"rtl": false,
"ord": 1,
"font": "Arial",
"size": 20
}
],
"sortf": 0,
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
"tmpls": [
{
"name": "Card 1",
"qfmt": "{{Front}}",
"did": null,
"bafmt": "",
"afmt": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
"ord": 0,
"bqfmt": ""
}
],
"latexPost": "\\end{document}",
"type": 0,
"id": "1354566092435",
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
"mod": 1369674106
}
}

11
AnkiStatic/Program.fs Normal file
View File

@@ -0,0 +1,11 @@
namespace AnkiStatic
open System.IO
module Program =
[<EntryPoint>]
let main _ =
let outputFile = FileInfo "/tmp/media"
let database = Sqlite.createEmptyPackage outputFile |> fun t -> t.Result
0

View File

@@ -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 = ""
}
[<RequireQualifiedAccess>]
module SerialisedNote =
let buildCards
(cardCountSoFar : int)
(deck : SerialisedDeck)
(easeFactor : int<ease>)
(interval : Interval)
(note : SerialisedNote)
: Card<SerialisedNote, SerialisedDeck> list
=
let primaryCard : Card<_, _> =
{
CreationDate = note.CreationDate
NotesId = note
DeckId = deck
Interval = interval
EaseFactor = easeFactor
Ordinal = 0
ModificationDate = note.CreationDate + TimeSpan.FromMilliseconds cardCountSoFar
UpdateSequenceNumber = -1
Type = CardType.New
Queue = Queue.New
Due = cardCountSoFar
NumberOfReviews = 0
NumberOfLapses = 0
Left = 0
Flags = 0
Data = ""
OriginalDue = 0
}
let otherCards =
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
[<RequireQualifiedAccess>]
module Card =
let translate<'note, 'deck>
(noteLookup : SerialisedNote -> 'note)
(deckLookup : SerialisedDeck -> 'deck)
(serialised : Card<SerialisedNote, SerialisedDeck>)
: Card<'note, 'deck>
=
{
CreationDate = serialised.CreationDate
NotesId = noteLookup serialised.NotesId
DeckId = deckLookup serialised.DeckId
Ordinal = serialised.Ordinal
ModificationDate = serialised.ModificationDate
UpdateSequenceNumber = serialised.UpdateSequenceNumber
Type = serialised.Type
Queue = serialised.Queue
Due = serialised.Due
Interval = serialised.Interval
EaseFactor = serialised.EaseFactor
NumberOfReviews = serialised.NumberOfReviews
NumberOfLapses = serialised.NumberOfLapses
Left = serialised.Left
Flags = serialised.Flags
Data = serialised.Data
OriginalDue = serialised.OriginalDue
}

View File

@@ -0,0 +1,108 @@
namespace AnkiStatic
open System
open System.Collections.Generic
type SerialisedCollection =
{
CreationDate : DateTimeOffset
Configuration : SerialisedCollectionConfiguration
DefaultModel : DateTimeOffset * SerialisedModel
NonDefaultModels : Map<DateTimeOffset, SerialisedModel>
DefaultDeck : SerialisedDeck
NonDefaultDecks : Map<DateTimeOffset, SerialisedDeck>
DefaultDeckConfiguration : SerialisedDeckConfiguration
NonDefaultDeckConfiguration : Map<DateTimeOffset, SerialisedDeckConfiguration>
Tags : string
}
type CollectionForSql =
{
Decks : Map<DateTimeOffset, Deck>
DecksInverse : SerialisedDeck -> DateTimeOffset
Models : Map<DateTimeOffset, ModelConfiguration<DateTimeOffset>>
ModelsInverse : SerialisedModel -> DateTimeOffset
Collection : Collection<DateTimeOffset, DateTimeOffset>
}
[<RequireQualifiedAccess>]
module SerialisedCollection =
let toSqlite (collection : SerialisedCollection) : CollectionForSql =
let decks, deckLookup =
let dict = Dictionary ()
let decks =
collection.NonDefaultDecks
|> Map.add (DateTimeOffset.FromUnixTimeMilliseconds 1) collection.DefaultDeck
|> Map.map (fun keyTimestamp deck ->
let converted = SerialisedDeck.ToDeck deck
dict.Add (deck, (keyTimestamp, converted))
converted
)
let deckLookup (d : SerialisedDeck) : DateTimeOffset * Deck =
// This could look up on reference equality rather than structural equality, for speed
match dict.TryGetValue d with
| true, v -> v
| false, _ ->
failwith
$"A model declared that it was attached to a deck, but that deck was not declared in the deck list: %+A{d}"
decks, deckLookup
let models, modelLookup =
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
}
}

View File

@@ -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<ease>
Intervals : IntervalConfiguration
Order : NewCardOrder
MaxNewPerDay : int
}
static member ToNewCardConfiguration (conf : SerialisedNewCardConfiguration) : NewCardConfiguration =
{
Bury = true
Delays = conf.Delays
InitialEase = conf.InitialEase
Intervals = conf.Intervals
Order = conf.Order
MaxNewPerDay = conf.MaxNewPerDay
Separate = true
}
type SerialisedReviewConfiguration =
{
EasinessPerEasyReview : float
Fuzz : float
IntervalFactor : int
MaxInterval : TimeSpan
PerDay : int
}
static member ToReviewConfiguration (conf : SerialisedReviewConfiguration) : ReviewConfiguration =
{
Bury = true
EasinessPerEasyReview = conf.EasinessPerEasyReview
Fuzz = conf.Fuzz
IntervalFactor = conf.IntervalFactor
MaxInterval = conf.MaxInterval
MinSpace = 1
PerDay = conf.PerDay
}
type SerialisedDeckConfiguration =
{
AutoPlay : bool
Lapse : LapseConfiguration
Name : string
New : SerialisedNewCardConfiguration
ReplayQuestionAudioWithAnswer : bool
Review : SerialisedReviewConfiguration
ShowTimer : bool
MaxTimerTimeout : TimeSpan
}
static member ToDeckConfiguration (conf : SerialisedDeckConfiguration) : DeckConfiguration =
{
AutoPlay = conf.AutoPlay
Lapse = conf.Lapse
MaxTaken = conf.MaxTimerTimeout
LastModified = DateTimeOffset.FromUnixTimeSeconds 0
Name = conf.Name
New = conf.New |> SerialisedNewCardConfiguration.ToNewCardConfiguration
ReplayQuestionAudioWithAnswer = conf.ReplayQuestionAudioWithAnswer
Review = conf.Review |> SerialisedReviewConfiguration.ToReviewConfiguration
ShowTimer = conf.ShowTimer
UpdateSequenceNumber = -1
}
type SerialisedCollectionConfiguration =
{
NewSpread : NewCardDistribution
CollapseTime : int
TimeLimit : TimeSpan
EstimateTimes : bool
ShowDueCounts : bool
SortBackwards : bool
}
static member ToCollectionConfiguration
(currentDeck : 'Deck option)
(activeDecks : 'Deck list)
(currentModel : 'Model)
(conf : SerialisedCollectionConfiguration)
: CollectionConfiguration<'Model, 'Deck>
=
{
CurrentDeck = currentDeck
ActiveDecks = activeDecks
NewSpread = conf.NewSpread
CollapseTime = conf.CollapseTime
TimeLimit = conf.TimeLimit
EstimateTimes = conf.EstimateTimes
ShowDueCounts = conf.ShowDueCounts
CurrentModel = currentModel
NextPosition =
// TODO: get this to pick up the incrementing counter
4
SortType =
// TODO: generalise this
"noteFld"
SortBackwards = conf.SortBackwards
AddToCurrent = true
}

352
AnkiStatic/Sqlite.fs Normal file
View File

@@ -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
[<RequireQualifiedAccess>]
module Sqlite =
let private executeCreateStatement (conn : SqliteConnection) (statement : string) =
task {
use cmd = conn.CreateCommand ()
cmd.CommandText <- statement
let! result = cmd.ExecuteNonQueryAsync ()
if result <> 0 then
return failwith "unexpectedly created a row in cards creation"
}
let createEmptyPackage (path : FileInfo) : Task<EmptyPackage> =
if path.FullName.Contains ';' then
failwith "Path contained connection string metacharacter ';', so aborting."
task {
// Connect to the SQLite database; create if not exists
let connectionString = $"Data Source=%s{path.FullName};"
use connection = new SqliteConnection (connectionString)
connection.Open ()
do!
executeCreateStatement
connection
"""
CREATE TABLE cards (
id integer primary key,
nid integer not null,
did integer not null,
ord integer not null,
mod integer not null,
usn integer not null,
type integer not null,
queue integer not null,
due integer not null,
ivl integer not null,
factor integer not null,
reps integer not null,
lapses integer not null,
left integer not null,
odue integer not null,
odid integer not null,
flags integer not null,
data text not null
)"""
do!
executeCreateStatement
connection
"""
CREATE TABLE col (
id integer primary key,
crt integer not null,
mod integer not null,
scm integer not null,
ver integer not null,
dty integer not null,
usn integer not null,
ls integer not null,
conf text not null,
models text not null,
decks text not null,
dconf text not null,
tags text not null
)"""
do!
executeCreateStatement
connection
"""
CREATE TABLE graves (
usn integer not null,
oid integer not null,
type integer not null
)"""
do!
executeCreateStatement
connection
"""
CREATE TABLE notes (
id integer primary key,
guid text not null,
mid integer not null,
mod integer not null,
usn integer not null,
tags text not null,
flds text not null,
sfld integer not null,
csum integer not null,
flags integer not null,
data text not null
)"""
do!
executeCreateStatement
connection
"""
CREATE TABLE revlog (
id integer primary key,
cid integer not null,
usn integer not null,
ease integer not null,
ivl integer not null,
lastIvl integer not null,
factor integer not null,
time integer not null,
type integer not null
)"""
do!
executeCreateStatement
connection
"""
CREATE INDEX ix_cards_nid on cards (nid);
CREATE INDEX ix_cards_sched on cards (did, queue, due);
CREATE INDEX ix_cards_usn on cards (usn);
CREATE INDEX ix_notes_csum on notes (csum);
CREATE INDEX ix_notes_usn on notes (usn);
CREATE INDEX ix_revlog_cid on revlog (cid);
CREATE INDEX ix_revlog_usn on revlog (usn)
"""
return EmptyPackage path
}
let createDecks (EmptyPackage sqliteDb) (collection : Collection<DateTimeOffset, DateTimeOffset>) : Task<Package> =
task {
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
use connection = new SqliteConnection (connectionString)
connection.Open ()
let cmd = connection.CreateCommand ()
cmd.Connection <- connection
cmd.CommandText <-
"""
INSERT INTO col
(crt, mod, scm, ver, dty, usn, ls, conf, models, decks, dconf, tags)
VALUES ($crt, $mod, $scm, $ver, $dty, $usn, $ls, $conf, $models, $decks, $dconf, $tags)
"""
cmd.Parameters.AddWithValue ("crt", collection.CreationDate.ToUnixTimeSeconds ())
|> ignore
cmd.Parameters.AddWithValue ("mod", collection.LastModified.ToUnixTimeSeconds ())
|> ignore
cmd.Parameters.AddWithValue ("scm", collection.LastSchemaModification.ToUnixTimeSeconds ())
|> ignore
cmd.Parameters.AddWithValue ("ver", collection.Version) |> ignore
cmd.Parameters.AddWithValue ("dty", collection.Dirty) |> ignore
cmd.Parameters.AddWithValue ("usn", collection.UpdateSequenceNumber) |> ignore
cmd.Parameters.AddWithValue ("ls", collection.LastSync.ToUnixTimeSeconds ())
|> ignore
cmd.Parameters.AddWithValue ("conf", collection.Configuration |> CollectionConfiguration.toJsonString)
|> ignore
cmd.Parameters.AddWithValue ("models", Collection.getJsonModelString collection)
|> ignore
cmd.Parameters.AddWithValue ("decks", Collection.getJsonDeckString collection)
|> ignore
cmd.Parameters.AddWithValue ("dconf", Collection.getDeckConfigurationString collection)
|> ignore
cmd.Parameters.AddWithValue ("tags", collection.Tags) |> ignore
let! rows = cmd.ExecuteNonQueryAsync ()
if rows <> 1 then
return failwith $"Failed to insert collection row (got: %i{rows})"
else
return Package sqliteDb
}
/// Returns the note ID for each input note, in order.
let createNotes (Package sqliteDb) (notes : Note<DateTimeOffset> list) : int64 IReadOnlyList Task =
// The Anki model is *absolutely* insane and uses time of creation of a note
// as a unique key.
// Work around this madness.
let notes =
let seenSoFar = HashSet ()
let mutable maxSoFar = DateTimeOffset.MinValue
notes
|> List.map (fun node ->
maxSoFar <- max maxSoFar node.LastModified
if not (seenSoFar.Add node.LastModified) then
maxSoFar <- maxSoFar + TimeSpan.FromMilliseconds 1.0
if not (seenSoFar.Add maxSoFar) then
failwith "logic has failed me"
{ node with
LastModified = maxSoFar
}
else
node
)
task {
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
use connection = new SqliteConnection (connectionString)
connection.Open ()
let cmd = connection.CreateCommand ()
cmd.CommandText <-
"""
INSERT INTO notes
(id, guid, mid, mod, usn, tags, flds, sfld, csum, flags, data)
VALUES ($id, $guid, $mid, $mod, $usn, $tags, $flds, $sfld, $csum, $flags, $data)
"""
cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("guid", SqliteType.Text) |> ignore
cmd.Parameters.Add ("mid", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("tags", SqliteType.Text) |> ignore
cmd.Parameters.Add ("flds", SqliteType.Text) |> ignore
cmd.Parameters.Add ("sfld", SqliteType.Text) |> ignore
cmd.Parameters.Add ("csum", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("data", SqliteType.Text) |> ignore
do! cmd.PrepareAsync ()
let result = ResizeArray ()
for note in notes do
cmd.Parameters.["id"].Value <- note.LastModified.ToUnixTimeMilliseconds ()
cmd.Parameters.["guid"].Value <- note.Guid |> Base91.toString
cmd.Parameters.["mid"].Value <- note.ModelId.ToUnixTimeMilliseconds ()
cmd.Parameters.["mod"].Value <- note.LastModified.ToUnixTimeSeconds ()
cmd.Parameters.["usn"].Value <- note.UpdateSequenceNumber
cmd.Parameters.["tags"].Value <-
match note.Tags with
| [] -> ""
| tags -> String.concat " " tags |> sprintf " %s "
cmd.Parameters.["flds"].Value <- note.Fields |> String.concat "\u001f"
cmd.Parameters.["sfld"].Value <-
match note.SortField with
| Choice1Of2 s -> s
| Choice2Of2 i -> i.ToString ()
cmd.Parameters.["csum"].Value <- note.Checksum
cmd.Parameters.["flags"].Value <- note.Flags
cmd.Parameters.["data"].Value <- note.Data
let! count = cmd.ExecuteNonQueryAsync ()
if count <> 1 then
failwithf "failed to insert note, got count: %i" count
let id = note.LastModified.ToUnixTimeMilliseconds ()
result.Add id
return result :> IReadOnlyList<_>
}
let createCards (Package sqliteDb) (cards : Card<int64, DateTimeOffset> list) =
task {
let connectionString = $"Data Source=%s{sqliteDb.FullName};"
use connection = new SqliteConnection (connectionString)
connection.Open ()
let cmd = connection.CreateCommand ()
cmd.CommandText <-
"""
INSERT INTO cards
(id, nid, did, ord, mod, usn, type, queue, due, ivl, factor, reps, lapses, left, odue, odid, flags, data)
VALUES (@id, @nid, @did, @ord, @mod, @usn, @type, @queue, @due, @ivl, @factor, @reps, @lapses, @left, @odue, @odid, @flags, @data)
"""
cmd.Parameters.Add ("id", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("nid", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("did", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("ord", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("mod", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("usn", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("type", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("queue", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("due", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("ivl", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("factor", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("reps", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("lapses", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("left", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("odue", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("odid", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("flags", SqliteType.Integer) |> ignore
cmd.Parameters.Add ("data", SqliteType.Text) |> ignore
do! cmd.PrepareAsync ()
for card in cards do
cmd.Parameters.["id"].Value <- card.ModificationDate.ToUnixTimeMilliseconds ()
cmd.Parameters.["nid"].Value <- card.NotesId
cmd.Parameters.["did"].Value <- card.DeckId.ToUnixTimeMilliseconds ()
cmd.Parameters.["ord"].Value <- card.Ordinal
cmd.Parameters.["mod"].Value <- card.ModificationDate.ToUnixTimeSeconds ()
cmd.Parameters.["usn"].Value <- card.UpdateSequenceNumber
cmd.Parameters.["type"].Value <- card.Type.ToInteger ()
cmd.Parameters.["queue"].Value <- card.Queue.ToInteger ()
cmd.Parameters.["due"].Value <- card.Due
cmd.Parameters.["ivl"].Value <- card.Interval.ToInteger ()
cmd.Parameters.["factor"].Value <- card.EaseFactor
cmd.Parameters.["reps"].Value <- card.NumberOfReviews
cmd.Parameters.["lapses"].Value <- card.NumberOfLapses
cmd.Parameters.["left"].Value <- card.Left
cmd.Parameters.["odue"].Value <- card.OriginalDue
cmd.Parameters.["odid"].Value <- 0
cmd.Parameters.["flags"].Value <- card.Flags
cmd.Parameters.["data"].Value <- card.Data
let! result = cmd.ExecuteNonQueryAsync ()
if result <> 1 then
failwith $"Did not get exactly 1 row back from insertion: %i{result}"
return ()
}

60
flake.lock generated Normal file
View File

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

28
flake.nix Normal file
View File

@@ -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 []
);
};
});
}

25
hooks/pre-push Executable file
View File

@@ -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()