Initial commit
This commit is contained in:
12
.config/dotnet-tools.json
Normal file
12
.config/dotnet-tools.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"version": 1,
|
||||
"isRoot": true,
|
||||
"tools": {
|
||||
"fantomas": {
|
||||
"version": "6.2.0",
|
||||
"commands": [
|
||||
"fantomas"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
41
.editorconfig
Normal file
41
.editorconfig
Normal 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
4
.gitattributes
vendored
Normal 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
10
.gitignore
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
bin/
|
||||
obj/
|
||||
/packages/
|
||||
riderModule.iml
|
||||
/_ReSharper.Caches/
|
||||
.idea/
|
||||
*.user
|
||||
*.DotSettings
|
||||
.profile*
|
||||
test.sqlite
|
10
.woodpecker/.all-checks-complete.yml
Normal file
10
.woodpecker/.all-checks-complete.yml
Normal 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
15
.woodpecker/.build.yml
Normal 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"
|
28
AnkiStatic.Test/AnkiStatic.Test.fsproj
Normal file
28
AnkiStatic.Test/AnkiStatic.Test.fsproj
Normal 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>
|
274
AnkiStatic.Test/LonghandExample.fs
Normal file
274
AnkiStatic.Test/LonghandExample.fs
Normal 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
29
AnkiStatic.Test/Tests.fs
Normal 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
22
AnkiStatic.sln
Normal 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
|
34
AnkiStatic/AnkiStatic.fsproj
Normal file
34
AnkiStatic/AnkiStatic.fsproj
Normal 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
24
AnkiStatic/Base91.fs
Normal 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
99
AnkiStatic/Domain/Card.fs
Normal 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
|
53
AnkiStatic/Domain/Collection.fs
Normal file
53
AnkiStatic/Domain/Collection.fs
Normal 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}"
|
50
AnkiStatic/Domain/CollectionConfiguration.fs
Normal file
50
AnkiStatic/Domain/CollectionConfiguration.fs
Normal 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
58
AnkiStatic/Domain/Deck.fs
Normal 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
|
130
AnkiStatic/Domain/DeckConfiguration.fs
Normal file
130
AnkiStatic/Domain/DeckConfiguration.fs
Normal 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
|
19
AnkiStatic/Domain/Grave.fs
Normal file
19
AnkiStatic/Domain/Grave.fs
Normal 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
143
AnkiStatic/Domain/Model.fs
Normal 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
43
AnkiStatic/Domain/Note.fs
Normal 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
|
25
AnkiStatic/Domain/Review.fs
Normal file
25
AnkiStatic/Domain/Review.fs
Normal 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}
|
||||
}}"""
|
16
AnkiStatic/Examples/example-collection-conf.json
Normal file
16
AnkiStatic/Examples/example-collection-conf.json
Normal 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
|
||||
}
|
44
AnkiStatic/Examples/example-collection-deck-conf.json
Normal file
44
AnkiStatic/Examples/example-collection-deck-conf.json
Normal 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
|
||||
}
|
||||
}
|
59
AnkiStatic/Examples/example-collection-decks.json
Normal file
59
AnkiStatic/Examples/example-collection-decks.json
Normal 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": ""
|
||||
}
|
||||
}
|
368
AnkiStatic/Examples/example-collection-models.json
Normal file
368
AnkiStatic/Examples/example-collection-models.json
Normal 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
11
AnkiStatic/Program.fs
Normal 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
|
117
AnkiStatic/SerialisedCard.fs
Normal file
117
AnkiStatic/SerialisedCard.fs
Normal 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
|
||||
}
|
108
AnkiStatic/SerialisedCollection.fs
Normal file
108
AnkiStatic/SerialisedCollection.fs
Normal 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
|
||||
}
|
||||
}
|
210
AnkiStatic/SerialisedDomain.fs
Normal file
210
AnkiStatic/SerialisedDomain.fs
Normal 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
352
AnkiStatic/Sqlite.fs
Normal 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
60
flake.lock
generated
Normal 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
28
flake.nix
Normal 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
25
hooks/pre-push
Executable 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()
|
Reference in New Issue
Block a user