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