Add JSON input (#3)
Co-authored-by: Smaug123 <patrick+github@patrickstevens.co.uk> Reviewed-on: #3
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -8,3 +8,5 @@ riderModule.iml
|
|||||||
*.DotSettings
|
*.DotSettings
|
||||||
.profile*
|
.profile*
|
||||||
test.sqlite
|
test.sqlite
|
||||||
|
__pycache__/
|
||||||
|
.idea
|
||||||
|
35
AnkiStatic.Lib/AnkiStatic.Lib.fsproj
Normal file
35
AnkiStatic.Lib/AnkiStatic.Lib.fsproj
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
|
</PropertyGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<Compile Include="AssemblyInfo.fs" />
|
||||||
|
<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="JsonDomain.fs" />
|
||||||
|
<Compile Include="Base91.fs" />
|
||||||
|
<Compile Include="Sqlite.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"/>
|
||||||
|
<Content Include="anki.schema.json" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.10"/>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
</Project>
|
6
AnkiStatic.Lib/AssemblyInfo.fs
Normal file
6
AnkiStatic.Lib/AssemblyInfo.fs
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
module AssemblyInfo
|
||||||
|
|
||||||
|
open System.Runtime.CompilerServices
|
||||||
|
|
||||||
|
[<assembly : InternalsVisibleTo("AnkiStatic.Test")>]
|
||||||
|
do ()
|
@@ -3,7 +3,7 @@ namespace AnkiStatic
|
|||||||
open System.Text
|
open System.Text
|
||||||
|
|
||||||
[<RequireQualifiedAccess>]
|
[<RequireQualifiedAccess>]
|
||||||
module Base91 =
|
module internal Base91 =
|
||||||
|
|
||||||
// Replicating the Anki algorithm
|
// Replicating the Anki algorithm
|
||||||
let private chars =
|
let private chars =
|
@@ -78,7 +78,7 @@ type ModelType =
|
|||||||
type ModelConfiguration<'Deck> =
|
type ModelConfiguration<'Deck> =
|
||||||
{
|
{
|
||||||
Css : string
|
Css : string
|
||||||
DeckId : 'Deck
|
DefaultDeckId : 'Deck
|
||||||
Fields : ModelField list
|
Fields : ModelField list
|
||||||
/// String which is added to terminate LaTeX expressions
|
/// String which is added to terminate LaTeX expressions
|
||||||
LatexPost : string
|
LatexPost : string
|
||||||
@@ -125,7 +125,7 @@ module ModelConfiguration =
|
|||||||
"vers": %s{vers},
|
"vers": %s{vers},
|
||||||
"name": %s{JsonSerializer.Serialize this.Name},
|
"name": %s{JsonSerializer.Serialize this.Name},
|
||||||
"tags": %s{tags},
|
"tags": %s{tags},
|
||||||
"did": %i{this.DeckId.ToUnixTimeMilliseconds ()},
|
"did": %i{this.DefaultDeckId.ToUnixTimeMilliseconds ()},
|
||||||
"usn": %i{this.UpdateSequenceNumber},
|
"usn": %i{this.UpdateSequenceNumber},
|
||||||
"flds": %s{flds},
|
"flds": %s{flds},
|
||||||
"sortf": %i{this.SortField},
|
"sortf": %i{this.SortField},
|
@@ -18,7 +18,7 @@ type ReviewConfiguration =
|
|||||||
$"""{{
|
$"""{{
|
||||||
"perDay": %i{this.PerDay},
|
"perDay": %i{this.PerDay},
|
||||||
"ivlFct": %i{this.IntervalFactor},
|
"ivlFct": %i{this.IntervalFactor},
|
||||||
"maxIvl": %i{int this.MaxInterval.TotalDays * 100},
|
"maxIvl": %i{int this.MaxInterval.TotalDays},
|
||||||
"minSpace": %i{this.MinSpace},
|
"minSpace": %i{this.MinSpace},
|
||||||
"ease4": %f{this.EasinessPerEasyReview},
|
"ease4": %f{this.EasinessPerEasyReview},
|
||||||
"fuzz": %f{this.Fuzz}
|
"fuzz": %f{this.Fuzz}
|
349
AnkiStatic.Lib/JsonDomain.fs
Normal file
349
AnkiStatic.Lib/JsonDomain.fs
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
namespace AnkiStatic
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.Collections.Generic
|
||||||
|
open System.Text.Json
|
||||||
|
open System.Text.Json.Serialization
|
||||||
|
|
||||||
|
type private LeechActionJsonConverter () =
|
||||||
|
inherit JsonConverter<LeechAction> ()
|
||||||
|
|
||||||
|
override this.Read (reader, _, options) =
|
||||||
|
match reader.GetString().ToLowerInvariant () with
|
||||||
|
| "suspend" -> LeechAction.Suspend
|
||||||
|
| "mark" -> LeechAction.Mark
|
||||||
|
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||||
|
|
||||||
|
override this.Write (writer, value, options) =
|
||||||
|
match value with
|
||||||
|
| LeechAction.Mark -> "mark"
|
||||||
|
| LeechAction.Suspend -> "suspend"
|
||||||
|
|> writer.WriteStringValue
|
||||||
|
|
||||||
|
type private NewCardOrderJsonConverter () =
|
||||||
|
inherit JsonConverter<NewCardOrder> ()
|
||||||
|
|
||||||
|
override this.Read (reader, _, options) =
|
||||||
|
match reader.GetString().ToLowerInvariant () with
|
||||||
|
| "random" -> NewCardOrder.Random
|
||||||
|
| "due" -> NewCardOrder.Due
|
||||||
|
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||||
|
|
||||||
|
override this.Write (writer, value, options) =
|
||||||
|
match value with
|
||||||
|
| NewCardOrder.Random -> "random"
|
||||||
|
| NewCardOrder.Due -> "due"
|
||||||
|
|> writer.WriteStringValue
|
||||||
|
|
||||||
|
type private ModelTypeJsonConverter () =
|
||||||
|
inherit JsonConverter<ModelType> ()
|
||||||
|
|
||||||
|
override this.Read (reader, _, options) =
|
||||||
|
match reader.GetString().ToLowerInvariant () with
|
||||||
|
| "standard" -> ModelType.Standard
|
||||||
|
| "cloze" -> ModelType.Cloze
|
||||||
|
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||||
|
|
||||||
|
override this.Write (writer, value, options) =
|
||||||
|
match value with
|
||||||
|
| ModelType.Standard -> "standard"
|
||||||
|
| ModelType.Cloze -> "cloze"
|
||||||
|
|> writer.WriteStringValue
|
||||||
|
|
||||||
|
type private NewCardDistributionJsonConverter () =
|
||||||
|
inherit JsonConverter<NewCardDistribution> ()
|
||||||
|
|
||||||
|
override this.Read (reader, _, options) =
|
||||||
|
match reader.GetString().ToLowerInvariant () with
|
||||||
|
| "distribute" -> NewCardDistribution.Distribute
|
||||||
|
| "first" -> NewCardDistribution.First
|
||||||
|
| "last" -> NewCardDistribution.Last
|
||||||
|
| s -> raise (JsonException $"could not deserialise: %s{s}")
|
||||||
|
|
||||||
|
override this.Write (writer, value, options) =
|
||||||
|
match value with
|
||||||
|
| NewCardDistribution.Distribute -> "distribute"
|
||||||
|
| NewCardDistribution.First -> "first"
|
||||||
|
| NewCardDistribution.Last -> "last"
|
||||||
|
|> writer.WriteStringValue
|
||||||
|
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module JsonCollection =
|
||||||
|
type JsonTemplate =
|
||||||
|
{
|
||||||
|
AnswerFormat : string
|
||||||
|
QuestionFormat : string
|
||||||
|
Name : string
|
||||||
|
BrowserAnswerFormat : string option
|
||||||
|
BrowserQuestionFormat : string option
|
||||||
|
}
|
||||||
|
|
||||||
|
static member ToInternal (this : JsonTemplate) : SerialisedCardTemplate =
|
||||||
|
{
|
||||||
|
AnswerFormat = this.AnswerFormat
|
||||||
|
QuestionFormat = this.QuestionFormat
|
||||||
|
Name = this.Name
|
||||||
|
BrowserAnswerFormat = this.BrowserAnswerFormat |> Option.defaultValue ""
|
||||||
|
BrowserQuestionFormat = this.BrowserQuestionFormat |> Option.defaultValue ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonMetadata =
|
||||||
|
{
|
||||||
|
// [<JsonRequired>]
|
||||||
|
CreationDate : DateTimeOffset
|
||||||
|
// [<JsonRequired>]
|
||||||
|
DefaultDeck : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
DefaultDeckConfiguration : SerialisedDeckConfiguration
|
||||||
|
/// Map into the string deck names
|
||||||
|
NonDefaultDecks : IReadOnlyDictionary<DateTimeOffset, string>
|
||||||
|
NonDefaultDeckConfigurations : IReadOnlyDictionary<DateTimeOffset, SerialisedDeckConfiguration>
|
||||||
|
Tags : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
DefaultModelName : string
|
||||||
|
SortBackwards : bool option
|
||||||
|
ShowDueCounts : bool option
|
||||||
|
NewSpread : NewCardDistribution
|
||||||
|
EstimateTimes : bool option
|
||||||
|
TimeLimitSeconds : int option
|
||||||
|
CollapseTimeSeconds : int option
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonNote =
|
||||||
|
{
|
||||||
|
Tags : string list option
|
||||||
|
// [<JsonRequired>]
|
||||||
|
SortFieldValue : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
AdditionalFieldValues : string list
|
||||||
|
CreationDate : DateTimeOffset option
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Model : string
|
||||||
|
}
|
||||||
|
|
||||||
|
static member internal ToInternal
|
||||||
|
(deck : SerialisedDeck)
|
||||||
|
(models : Map<string, SerialisedModel>)
|
||||||
|
(this : JsonNote)
|
||||||
|
: SerialisedNote
|
||||||
|
=
|
||||||
|
{
|
||||||
|
Deck = deck
|
||||||
|
CreationDate = this.CreationDate |> Option.defaultValue DateTimeOffset.UnixEpoch
|
||||||
|
Model = models.[this.Model]
|
||||||
|
Tags = this.Tags |> Option.defaultValue []
|
||||||
|
ValueOfSortField = this.SortFieldValue
|
||||||
|
ValuesOfAdditionalFields = this.AdditionalFieldValues
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonDeck =
|
||||||
|
{
|
||||||
|
ExtendedReviewLimit : int option
|
||||||
|
ExtendedNewCardLimit : int option
|
||||||
|
Collapsed : bool option
|
||||||
|
BrowserCollapsed : bool option
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Description : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Notes : JsonNote list
|
||||||
|
}
|
||||||
|
|
||||||
|
static member internal ToInternal (name : string) (deck : JsonDeck) : SerialisedDeck =
|
||||||
|
{
|
||||||
|
Name = name
|
||||||
|
ExtendedReviewLimit = deck.ExtendedReviewLimit
|
||||||
|
ExtendedNewCardLimit = deck.ExtendedNewCardLimit
|
||||||
|
Collapsed = deck.Collapsed |> Option.defaultValue false
|
||||||
|
BrowserCollapsed = deck.BrowserCollapsed |> Option.defaultValue false
|
||||||
|
Description = deck.Description
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonField =
|
||||||
|
{
|
||||||
|
// [<JsonRequired>]
|
||||||
|
DisplayName : string
|
||||||
|
Font : string option
|
||||||
|
RightToLeft : bool option
|
||||||
|
FontSize : int option
|
||||||
|
Sticky : bool option
|
||||||
|
}
|
||||||
|
|
||||||
|
static member internal ToInternal (field : JsonField) : SerialisedModelField =
|
||||||
|
{
|
||||||
|
Font = field.Font |> Option.defaultValue "Arial"
|
||||||
|
Name = field.DisplayName
|
||||||
|
RightToLeft = field.RightToLeft |> Option.defaultValue false
|
||||||
|
FontSize = field.FontSize |> Option.defaultValue 20
|
||||||
|
Sticky = field.Sticky |> Option.defaultValue false
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonModel =
|
||||||
|
{
|
||||||
|
Css : string option
|
||||||
|
/// Name of a field
|
||||||
|
// [<JsonRequired>]
|
||||||
|
SortField : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
AdditionalFields : string list
|
||||||
|
LatexPost : string option
|
||||||
|
LatexPre : string option
|
||||||
|
// TODO: is this required?
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Name : string
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Templates : string list
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Type : ModelType
|
||||||
|
DefaultDeck : string option
|
||||||
|
ModificationTime : DateTimeOffset
|
||||||
|
}
|
||||||
|
|
||||||
|
static member internal ToInternal
|
||||||
|
(defaultDeck : SerialisedDeck)
|
||||||
|
(standardTemplates : IReadOnlyDictionary<string, SerialisedCardTemplate>)
|
||||||
|
(clozeTemplates : IReadOnlyDictionary<string, SerialisedCardTemplate>)
|
||||||
|
(decks : Map<string, SerialisedDeck>)
|
||||||
|
(fields : Map<string, SerialisedModelField>)
|
||||||
|
(this : JsonModel)
|
||||||
|
: SerialisedModel
|
||||||
|
=
|
||||||
|
{
|
||||||
|
Css =
|
||||||
|
this.Css
|
||||||
|
|> Option.defaultValue (
|
||||||
|
JsonSerializer.Serialize
|
||||||
|
".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n"
|
||||||
|
)
|
||||||
|
AdditionalFields = this.AdditionalFields |> List.map (fun field -> Map.find field fields)
|
||||||
|
LatexPost =
|
||||||
|
this.LatexPost
|
||||||
|
|> Option.defaultValue (JsonSerializer.Serialize @"\end{document}")
|
||||||
|
LatexPre =
|
||||||
|
this.LatexPre
|
||||||
|
|> Option.defaultValue (
|
||||||
|
JsonSerializer.Serialize
|
||||||
|
"\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n"
|
||||||
|
)
|
||||||
|
Name = this.Name
|
||||||
|
SortField = fields.[this.SortField]
|
||||||
|
Templates =
|
||||||
|
match this.Type with
|
||||||
|
| ModelType.Cloze -> this.Templates |> List.map (fun t -> clozeTemplates.[t])
|
||||||
|
| ModelType.Standard -> this.Templates |> List.map (fun t -> standardTemplates.[t])
|
||||||
|
Type = this.Type
|
||||||
|
DefaultDeck =
|
||||||
|
match this.DefaultDeck with
|
||||||
|
| None -> defaultDeck
|
||||||
|
| Some deck -> decks.[deck]
|
||||||
|
}
|
||||||
|
|
||||||
|
type JsonCollection =
|
||||||
|
{
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Metadata : JsonMetadata
|
||||||
|
// [<JsonRequired>]
|
||||||
|
StandardTemplates : IReadOnlyDictionary<string, JsonTemplate>
|
||||||
|
// [<JsonRequired>]
|
||||||
|
ClozeTemplates : IReadOnlyDictionary<string, JsonTemplate>
|
||||||
|
/// Map of name to deck
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Decks : IReadOnlyDictionary<string, JsonDeck>
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Fields : IReadOnlyDictionary<string, JsonField>
|
||||||
|
// [<JsonRequired>]
|
||||||
|
Models : IReadOnlyDictionary<string, JsonModel>
|
||||||
|
}
|
||||||
|
|
||||||
|
let internal deserialise (s : string) : JsonCollection =
|
||||||
|
let opts = JsonSerializerOptions ()
|
||||||
|
opts.Converters.Add (LeechActionJsonConverter ())
|
||||||
|
opts.Converters.Add (NewCardDistributionJsonConverter ())
|
||||||
|
opts.Converters.Add (NewCardOrderJsonConverter ())
|
||||||
|
opts.Converters.Add (ModelTypeJsonConverter ())
|
||||||
|
opts.PropertyNameCaseInsensitive <- true
|
||||||
|
JsonSerializer.Deserialize (s, opts)
|
||||||
|
|
||||||
|
let toInternal (collection : JsonCollection) : SerialisedCollection * SerialisedNote list =
|
||||||
|
let decks =
|
||||||
|
collection.Decks
|
||||||
|
|> Seq.map (fun (KeyValue (deckName, deck)) -> deckName, JsonDeck.ToInternal deckName deck)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let fields =
|
||||||
|
collection.Fields
|
||||||
|
|> Seq.map (fun (KeyValue (fieldName, field)) -> fieldName, JsonField.ToInternal field)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let standardTemplates =
|
||||||
|
collection.StandardTemplates
|
||||||
|
|> Seq.map (fun (KeyValue (key, template)) -> key, JsonTemplate.ToInternal template)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let clozeTemplates =
|
||||||
|
collection.ClozeTemplates
|
||||||
|
|> Seq.map (fun (KeyValue (key, template)) -> key, JsonTemplate.ToInternal template)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let defaultDeck = decks.[collection.Metadata.DefaultDeck]
|
||||||
|
|
||||||
|
let models : Map<string, SerialisedModel> =
|
||||||
|
collection.Models
|
||||||
|
|> Seq.map (fun (KeyValue (modelName, model)) ->
|
||||||
|
let model =
|
||||||
|
JsonModel.ToInternal defaultDeck standardTemplates clozeTemplates decks fields model
|
||||||
|
|
||||||
|
modelName, model
|
||||||
|
)
|
||||||
|
|> Map.ofSeq
|
||||||
|
|
||||||
|
let notes =
|
||||||
|
collection.Decks
|
||||||
|
|> Seq.map (fun (KeyValue (deckName, deck)) ->
|
||||||
|
deck.Notes |> Seq.map (JsonNote.ToInternal decks.[deckName] models)
|
||||||
|
)
|
||||||
|
|> Seq.concat
|
||||||
|
|> List.ofSeq
|
||||||
|
|
||||||
|
let collection =
|
||||||
|
{
|
||||||
|
CreationDate = collection.Metadata.CreationDate
|
||||||
|
Configuration =
|
||||||
|
{
|
||||||
|
NewSpread = collection.Metadata.NewSpread
|
||||||
|
CollapseTime = collection.Metadata.CollapseTimeSeconds |> Option.defaultValue 1200
|
||||||
|
TimeLimit =
|
||||||
|
collection.Metadata.TimeLimitSeconds
|
||||||
|
|> Option.defaultValue 0
|
||||||
|
|> float<int>
|
||||||
|
|> TimeSpan.FromSeconds
|
||||||
|
EstimateTimes = collection.Metadata.EstimateTimes |> Option.defaultValue false
|
||||||
|
ShowDueCounts = collection.Metadata.ShowDueCounts |> Option.defaultValue true
|
||||||
|
SortBackwards = collection.Metadata.SortBackwards |> Option.defaultValue false
|
||||||
|
}
|
||||||
|
DefaultModel =
|
||||||
|
let model = models.[collection.Metadata.DefaultModelName]
|
||||||
|
collection.Models.[collection.Metadata.DefaultModelName].ModificationTime, model
|
||||||
|
NonDefaultModels =
|
||||||
|
collection.Models
|
||||||
|
|> Seq.choose (fun (KeyValue (modelKey, _model)) ->
|
||||||
|
if modelKey <> collection.Metadata.DefaultModelName then
|
||||||
|
let time = collection.Models.[modelKey].ModificationTime
|
||||||
|
Some (time, models.[modelKey])
|
||||||
|
else
|
||||||
|
None
|
||||||
|
)
|
||||||
|
|> Map.ofSeq
|
||||||
|
DefaultDeck = defaultDeck
|
||||||
|
NonDefaultDecks =
|
||||||
|
collection.Metadata.NonDefaultDecks
|
||||||
|
|> Seq.map (fun (KeyValue (time, deck)) -> time, decks.[deck])
|
||||||
|
|> Map.ofSeq
|
||||||
|
DefaultDeckConfiguration = collection.Metadata.DefaultDeckConfiguration
|
||||||
|
NonDefaultDeckConfiguration =
|
||||||
|
collection.Metadata.NonDefaultDeckConfigurations
|
||||||
|
|> Seq.map (fun kvp -> kvp.Key, kvp.Value)
|
||||||
|
|> Map.ofSeq
|
||||||
|
Tags = collection.Metadata.Tags
|
||||||
|
}
|
||||||
|
|
||||||
|
collection, notes
|
@@ -1,9 +1,11 @@
|
|||||||
namespace AnkiStatic
|
namespace AnkiStatic
|
||||||
|
|
||||||
open System
|
open System
|
||||||
|
open System.Text.RegularExpressions
|
||||||
|
|
||||||
type SerialisedNote =
|
type SerialisedNote =
|
||||||
{
|
{
|
||||||
|
Deck : SerialisedDeck
|
||||||
CreationDate : DateTimeOffset
|
CreationDate : DateTimeOffset
|
||||||
Model : SerialisedModel
|
Model : SerialisedModel
|
||||||
Tags : string list
|
Tags : string list
|
||||||
@@ -63,8 +65,17 @@ module SerialisedNote =
|
|||||||
}
|
}
|
||||||
|
|
||||||
let otherCards =
|
let otherCards =
|
||||||
note.Model.AdditionalFields
|
let numberOfFields =
|
||||||
|> List.mapi (fun i _field ->
|
match note.Model.Type with
|
||||||
|
| ModelType.Cloze ->
|
||||||
|
// Awful heuristic.
|
||||||
|
// The first match is the primary field, which has already been counted,
|
||||||
|
// hence the -1.
|
||||||
|
Regex.Matches(note.ValueOfSortField, @"\{\{c\d+::(.+?)\}\}").Count - 1
|
||||||
|
| ModelType.Standard -> note.Model.AdditionalFields.Length
|
||||||
|
|
||||||
|
[ 0 .. numberOfFields - 1 ]
|
||||||
|
|> List.map (fun i ->
|
||||||
{
|
{
|
||||||
CreationDate = note.CreationDate
|
CreationDate = note.CreationDate
|
||||||
NotesId = note
|
NotesId = note
|
@@ -51,16 +51,25 @@ module SerialisedCollection =
|
|||||||
|
|
||||||
decks, deckLookup
|
decks, deckLookup
|
||||||
|
|
||||||
let models, modelLookup =
|
let models, modelLookup, _correctedCurrentDate =
|
||||||
let dict = Dictionary ()
|
let dict = Dictionary ()
|
||||||
|
|
||||||
|
let defaultModelDate =
|
||||||
|
let mutable currentDate =
|
||||||
|
fst collection.DefaultModel + TimeSpan.FromMilliseconds 1.0
|
||||||
|
|
||||||
|
while collection.NonDefaultModels.ContainsKey currentDate do
|
||||||
|
currentDate <- currentDate + TimeSpan.FromMilliseconds 1.0
|
||||||
|
|
||||||
|
currentDate
|
||||||
|
|
||||||
let models =
|
let models =
|
||||||
collection.NonDefaultModels
|
collection.NonDefaultModels
|
||||||
|> Map.add (fst collection.DefaultModel) (snd collection.DefaultModel)
|
|> Map.add defaultModelDate (snd collection.DefaultModel)
|
||||||
|> Map.map (fun modelTimestamp v ->
|
|> Map.map (fun modelTimestamp v ->
|
||||||
let deckTimestamp, _deck = deckLookup v.Deck
|
let defaultDeckTimestamp, _deck = deckLookup v.DefaultDeck
|
||||||
dict.Add (v, modelTimestamp)
|
dict.Add (v, modelTimestamp)
|
||||||
SerialisedModel.ToModel v deckTimestamp
|
SerialisedModel.ToModel v defaultDeckTimestamp
|
||||||
)
|
)
|
||||||
|
|
||||||
let modelLookup (m : SerialisedModel) : DateTimeOffset =
|
let modelLookup (m : SerialisedModel) : DateTimeOffset =
|
||||||
@@ -70,7 +79,7 @@ module SerialisedCollection =
|
|||||||
failwith
|
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}"
|
$"A note declared that it satisfied a model, but that model was not declared in the model list:\n\nDesired: %+A{m}\n\nAvailable: %+A{dict}"
|
||||||
|
|
||||||
models, modelLookup
|
models, modelLookup, defaultModelDate
|
||||||
|
|
||||||
let defaultDeck, _ = deckLookup collection.DefaultDeck
|
let defaultDeck, _ = deckLookup collection.DefaultDeck
|
||||||
|
|
@@ -82,15 +82,23 @@ type SerialisedModel =
|
|||||||
Name : string
|
Name : string
|
||||||
/// Which field the browser uses to sort by
|
/// Which field the browser uses to sort by
|
||||||
SortField : SerialisedModelField
|
SortField : SerialisedModelField
|
||||||
|
/// An invariant which is not maintained at construction:
|
||||||
|
/// if the Type is Cloze, then this templates list must have length exactly 1.
|
||||||
Templates : SerialisedCardTemplate list
|
Templates : SerialisedCardTemplate list
|
||||||
Type : ModelType
|
Type : ModelType
|
||||||
Deck : SerialisedDeck
|
DefaultDeck : SerialisedDeck
|
||||||
}
|
}
|
||||||
|
|
||||||
static member ToModel<'Deck> (s : SerialisedModel) (deck : 'Deck) : ModelConfiguration<'Deck> =
|
static member ToModel<'Deck> (s : SerialisedModel) (deck : 'Deck) : ModelConfiguration<'Deck> =
|
||||||
|
match s.Type, s.Templates with
|
||||||
|
| ModelType.Cloze, [] -> failwith $"A cloze model must have exactly one template, but got 0: %+A{s}"
|
||||||
|
| ModelType.Cloze, _ :: _ :: _ ->
|
||||||
|
failwith $"A cloze model must have exactly one template, but got at least 2: %+A{s}"
|
||||||
|
| _, _ -> ()
|
||||||
|
|
||||||
{
|
{
|
||||||
Css = s.Css
|
Css = s.Css
|
||||||
DeckId = deck
|
DefaultDeckId = deck
|
||||||
Fields =
|
Fields =
|
||||||
(s.SortField :: s.AdditionalFields)
|
(s.SortField :: s.AdditionalFields)
|
||||||
|> List.mapi SerialisedModelField.ToModelField
|
|> List.mapi SerialisedModelField.ToModelField
|
@@ -3,6 +3,7 @@ namespace AnkiStatic
|
|||||||
open System
|
open System
|
||||||
open System.Collections.Generic
|
open System.Collections.Generic
|
||||||
open System.IO
|
open System.IO
|
||||||
|
open System.IO.Compression
|
||||||
open Microsoft.Data.Sqlite
|
open Microsoft.Data.Sqlite
|
||||||
open System.Threading.Tasks
|
open System.Threading.Tasks
|
||||||
|
|
||||||
@@ -350,3 +351,65 @@ VALUES (@id, @nid, @did, @ord, @mod, @usn, @type, @queue, @due, @ivl, @factor, @
|
|||||||
|
|
||||||
return ()
|
return ()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let writeAll
|
||||||
|
(rng : Random)
|
||||||
|
(collection : CollectionForSql)
|
||||||
|
(notes : SerialisedNote list)
|
||||||
|
(outputFile : FileInfo)
|
||||||
|
: Task<unit>
|
||||||
|
=
|
||||||
|
let renderedNotes, lookupNote =
|
||||||
|
let dict = Dictionary ()
|
||||||
|
|
||||||
|
let buffer = BitConverter.GetBytes (uint64 0)
|
||||||
|
|
||||||
|
let result =
|
||||||
|
notes
|
||||||
|
|> List.mapi (fun i note ->
|
||||||
|
rng.NextBytes buffer
|
||||||
|
let guid = BitConverter.ToUInt64 (buffer, 0)
|
||||||
|
dict.Add (note, i)
|
||||||
|
SerialisedNote.ToNote guid collection.ModelsInverse note
|
||||||
|
)
|
||||||
|
|
||||||
|
let lookupNote (note : SerialisedNote) : int =
|
||||||
|
match dict.TryGetValue note with
|
||||||
|
| true, v -> v
|
||||||
|
| false, _ ->
|
||||||
|
failwith
|
||||||
|
$"A card declared that it was associated with a note, but that note was not inserted.\nDesired: %+A{note}\nAvailable:\n%+A{dict}"
|
||||||
|
|
||||||
|
result, lookupNote
|
||||||
|
|
||||||
|
let tempFile = Path.GetTempFileName () |> FileInfo
|
||||||
|
|
||||||
|
task {
|
||||||
|
let! package = createEmptyPackage tempFile
|
||||||
|
|
||||||
|
let! written = collection.Collection |> createDecks package
|
||||||
|
|
||||||
|
let! noteIds = createNotes written renderedNotes
|
||||||
|
|
||||||
|
let _, _, cards =
|
||||||
|
((0, 0, []), notes)
|
||||||
|
||> List.fold (fun (count, iter, cards) note ->
|
||||||
|
let built =
|
||||||
|
SerialisedNote.buildCards count note.Deck 1000<ease> Interval.Unset note
|
||||||
|
|> List.map (Card.translate (fun note -> noteIds.[lookupNote note]) collection.DecksInverse)
|
||||||
|
|
||||||
|
built.Length + count, iter + 1, built @ cards
|
||||||
|
)
|
||||||
|
|
||||||
|
do! createCards written cards
|
||||||
|
|
||||||
|
use outputStream = outputFile.OpenWrite ()
|
||||||
|
use archive = new ZipArchive (outputStream, ZipArchiveMode.Create, true)
|
||||||
|
|
||||||
|
let entry = archive.CreateEntry "collection.anki2"
|
||||||
|
use entryStream = entry.Open ()
|
||||||
|
use contents = tempFile.OpenRead ()
|
||||||
|
do! contents.CopyToAsync entryStream
|
||||||
|
|
||||||
|
return ()
|
||||||
|
}
|
555
AnkiStatic.Lib/anki.schema.json
Normal file
555
AnkiStatic.Lib/anki.schema.json
Normal file
@@ -0,0 +1,555 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||||
|
"title": "Collection",
|
||||||
|
"description": "An entire Anki collection.",
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"definitions": {
|
||||||
|
"reviewConfiguration": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"easinessPerEasyReview",
|
||||||
|
"fuzz",
|
||||||
|
"intervalFactor",
|
||||||
|
"maxIntervalDays",
|
||||||
|
"perDay"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"easinessPerEasyReview": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "An extra multiplier applied to a card's Ease when you rate it Easy"
|
||||||
|
},
|
||||||
|
"fuzz": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "Fudge factor to prevent cards which were created and reviewed together from all appearing together. Semantics unknown; observed to be 0.05 in the wild"
|
||||||
|
},
|
||||||
|
"intervalFactor": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Multiplication factor applied to the intervals Anki generates; unknown semantics, observed to be 1 in the wild"
|
||||||
|
},
|
||||||
|
"maxIntervalDays": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Maximum number of days that can pass between reviews",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"perDay": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of cards to review per day",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"lapse": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"delays",
|
||||||
|
"leechAction",
|
||||||
|
"leechFails",
|
||||||
|
"minInterval",
|
||||||
|
"multiplier"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"delays": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "Successive delays in days between the learning steps of the cards",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"leechAction": {
|
||||||
|
"enum": [
|
||||||
|
"suspend",
|
||||||
|
"mark"
|
||||||
|
],
|
||||||
|
"description": "What to do when a card in this deck becomes a leech"
|
||||||
|
},
|
||||||
|
"leechFails": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Number of times a review of a card must fail before the card is marked as a leech",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"minInterval": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Lower limit of the new interval after a card is marked leech, in days",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"multiplier": {
|
||||||
|
"type": "number",
|
||||||
|
"description": "The multiplier applied to a review interval when answering Again.",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"newCardConfiguration": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"delays",
|
||||||
|
"initialEase",
|
||||||
|
"intervals",
|
||||||
|
"order",
|
||||||
|
"maxNewPerDay"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"delays": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "The list of successive delays between learning steps of new cards, in minutes",
|
||||||
|
"items": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"initialEase": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "100x the multiplier for how much the Good button will delay the next review, so 2500 delays the next review 2.5x on a Good review",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"intervals": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "List of delays when leaving learning mode after pressing the various buttons",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"good",
|
||||||
|
"easy",
|
||||||
|
"unused"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"good": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The delay in days after pressing the Good button",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"easy": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The delay in days after pressing the Easy button",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"unused": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "An unused delay, probably set this to 7",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"order": {
|
||||||
|
"enum": [
|
||||||
|
"random",
|
||||||
|
"due"
|
||||||
|
],
|
||||||
|
"description": "How to display new cards - by order of due date, or at random"
|
||||||
|
},
|
||||||
|
"maxNewPerDay": {
|
||||||
|
"description": "How many new cards can be shown per day",
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"field": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "A field of a note, holding a single piece of data; a card may ask you to recall a field, for example",
|
||||||
|
"required": [
|
||||||
|
"displayName"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"displayName": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The name used inside templates to refer to this field"
|
||||||
|
},
|
||||||
|
"rightToLeft": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"sticky": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"fontSize": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"font": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "e.g. Arial"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deckConfiguration": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Configuration of a deck, without any mention of its notes",
|
||||||
|
"required": [
|
||||||
|
"autoPlay",
|
||||||
|
"lapse",
|
||||||
|
"name",
|
||||||
|
"new",
|
||||||
|
"replayQuestionAudioWithAnswer",
|
||||||
|
"review",
|
||||||
|
"showTimer",
|
||||||
|
"maxTimerTimeoutSeconds"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"autoPlay": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to play audio immediately on showing the question"
|
||||||
|
},
|
||||||
|
"lapse": {
|
||||||
|
"description": "What to do with lapsed cards",
|
||||||
|
"$ref": "#/definitions/lapse"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Name of the deck configuration, which as far as I can tell is unused"
|
||||||
|
},
|
||||||
|
"new": {
|
||||||
|
"description": "How to show new cards from the deck",
|
||||||
|
"$ref": "#/definitions/newCardConfiguration"
|
||||||
|
},
|
||||||
|
"replayQuestionAudioWithAnswer": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to replay question audio when the answer is displayed"
|
||||||
|
},
|
||||||
|
"review": {
|
||||||
|
"description": "Configuration governing how card metadata changes with each review",
|
||||||
|
"$ref": "#/definitions/reviewConfiguration"
|
||||||
|
},
|
||||||
|
"showTimer": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to show a timer while cards are open"
|
||||||
|
},
|
||||||
|
"maxTimerTimeoutSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "The time in seconds after which to stop the timer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"standardTemplate": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Each non-cloze note gets turned into `n` cards by applying `n` templates to the note. The template determines which fields get shown where on the card.",
|
||||||
|
"required": [
|
||||||
|
"answerFormat",
|
||||||
|
"questionFormat",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"answerFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How the answer of this card gets displayed. You can refer to fields of the note with {{FieldName}}.",
|
||||||
|
"example": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}"
|
||||||
|
},
|
||||||
|
"questionFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How the question of this card gets displayed. You can refer to fields of the note with {{FieldName}}.",
|
||||||
|
"example": "{{Front}}"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A display name for this card-template. It'll probably be confusing if you reuse this name across multiple templates in the same card, but I think it's allowed."
|
||||||
|
},
|
||||||
|
"browserAnswerFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nobody seems to know what this is, but it's used for displaying the answer in the card browser"
|
||||||
|
},
|
||||||
|
"browserQuestionFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nobody seems to know what this is, but it's used for displaying the question in the card browser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clozeTemplate": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Each cloze note gets turned into `n` cards by removing one of the `n cloze deletions in that card.",
|
||||||
|
"required": [
|
||||||
|
"answerFormat",
|
||||||
|
"questionFormat",
|
||||||
|
"name"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"answerFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How the answer of this card gets displayed. You can refer to the text generated by deleting a cloze from FieldName with {{cloze:FieldName}}, and other fields with {{FieldName}}.",
|
||||||
|
"example": "{{cloze:Text}}<br>\n{{Extra}}"
|
||||||
|
},
|
||||||
|
"questionFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "How the question of this card gets displayed. You can refer to the text generated by deleting a cloze from FieldName with {{cloze:FieldName}}, and other fields with {{FieldName}}.",
|
||||||
|
"example": "{{cloze:Text}}"
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "A display name for this card-template. It'll probably be confusing if you reuse this name across multiple templates in the same card, but I think it's allowed."
|
||||||
|
},
|
||||||
|
"browserAnswerFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nobody seems to know what this is, but it's used for displaying the answer in the card browser"
|
||||||
|
},
|
||||||
|
"browserQuestionFormat": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Nobody seems to know what this is, but it's used for displaying the question in the card browser"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"description": "Metadata governing this entire collection.",
|
||||||
|
"required": [
|
||||||
|
"creationDate",
|
||||||
|
"defaultDeck",
|
||||||
|
"defaultDeckConfiguration",
|
||||||
|
"defaultModelName"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"creationDate": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time",
|
||||||
|
"description": "Displayed creation date for this collection"
|
||||||
|
},
|
||||||
|
"collapseTimeSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "If there are no more cards to review now, but the next card in the 'learning' state is due in less than this number of seconds, show it now.",
|
||||||
|
"example": 1200
|
||||||
|
},
|
||||||
|
"timeLimitSeconds": {
|
||||||
|
"type": "integer",
|
||||||
|
"description": "Time-boxing limit when reviewing cards. Whenever this number of seconds elapse, Anki tells you how many card you reviewed. Omit for \"no limit\".",
|
||||||
|
"minimum": 1
|
||||||
|
},
|
||||||
|
"estimateTimes": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show the next review time above each answer button"
|
||||||
|
},
|
||||||
|
"newSpread": {
|
||||||
|
"enum": [
|
||||||
|
"distribute",
|
||||||
|
"last",
|
||||||
|
"first"
|
||||||
|
],
|
||||||
|
"description": "How to decide which new cards to present to you"
|
||||||
|
},
|
||||||
|
"showDueCounts": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Show remaining card count above answer buttons"
|
||||||
|
},
|
||||||
|
"sortBackwards": {
|
||||||
|
"type": "boolean",
|
||||||
|
"description": "Whether to show cards in the browser in decreasing order, whatever that means"
|
||||||
|
},
|
||||||
|
"defaultDeck": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The default deck of this collection, whatever that means. The values must be keys of the `decks` mapping."
|
||||||
|
},
|
||||||
|
"defaultDeckConfiguration": {
|
||||||
|
"description": "Configuration of the default deck",
|
||||||
|
"$ref": "#/definitions/deckConfiguration"
|
||||||
|
},
|
||||||
|
"tags": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "TODO what are the semantics of this",
|
||||||
|
"example": "{}"
|
||||||
|
},
|
||||||
|
"defaultModelName": {
|
||||||
|
"description": "The default model for new cards in this collection. The values must be keys of the `models` mapping",
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"nonDefaultDecks": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "TODO"
|
||||||
|
},
|
||||||
|
"nonDefaultDeckConfigurations": {
|
||||||
|
"type": "object",
|
||||||
|
"description": "TODO"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"note": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"model",
|
||||||
|
"creationDate",
|
||||||
|
"sortFieldValue",
|
||||||
|
"additionalFieldValues",
|
||||||
|
"tags"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"tags": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"sortFieldValue": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The text that appears in the primary (sorting) field of this note"
|
||||||
|
},
|
||||||
|
"additionalFieldValues": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "The text that appears in each non-primary field of this note",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"creationDate": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "The value must be a key of the `models` mapping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"deck": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"description",
|
||||||
|
"notes"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"extendedReviewLimit": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "When doing an Extended Review custom study, the number of cards to be reviewed."
|
||||||
|
},
|
||||||
|
"extendedNewCardLimit": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0,
|
||||||
|
"description": "When doing an Extended New custom study, the number of new cards to be shown."
|
||||||
|
},
|
||||||
|
"collapsed": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"browserCollapsed": {
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"description": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"notes": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"$ref": "#/definitions/note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"model": {
|
||||||
|
"type": "object",
|
||||||
|
"additionalProperties": false,
|
||||||
|
"required": [
|
||||||
|
"sortField",
|
||||||
|
"additionalFields",
|
||||||
|
"type",
|
||||||
|
"name",
|
||||||
|
"templates"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"modificationTime": {
|
||||||
|
"type": "string",
|
||||||
|
"format": "date-time"
|
||||||
|
},
|
||||||
|
"css": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"latexPost": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"latexPre": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"sortField": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "TODO what relation does this have with Templates?"
|
||||||
|
},
|
||||||
|
"additionalFields": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"templates": {
|
||||||
|
"type": "array",
|
||||||
|
"description": "These values must be keys of `clozeTemplates` if `type` is \"cloze\", or of `standardTemplates` if `type` is \"standard\".",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"type": {
|
||||||
|
"enum": [
|
||||||
|
"cloze",
|
||||||
|
"standard"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"name": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Display name"
|
||||||
|
},
|
||||||
|
"defaultDeck": {
|
||||||
|
"type": "string",
|
||||||
|
"description": "Deck into which new notes under this model are placed by default. The value must be a key of the `decks` mapping."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"metadata",
|
||||||
|
"standardTemplates",
|
||||||
|
"clozeTemplates",
|
||||||
|
"decks",
|
||||||
|
"fields",
|
||||||
|
"models"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"metadata": {
|
||||||
|
"$ref": "#/definitions/metadata"
|
||||||
|
},
|
||||||
|
"standardTemplates": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".*": {
|
||||||
|
"$ref": "#/definitions/standardTemplate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clozeTemplates": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".*": {
|
||||||
|
"$ref": "#/definitions/clozeTemplate"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".*": {
|
||||||
|
"$ref": "#/definitions/deck"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".*": {
|
||||||
|
"$ref": "#/definitions/field"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"type": "object",
|
||||||
|
"patternProperties": {
|
||||||
|
".*": {
|
||||||
|
"$ref": "#/definitions/model"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -8,8 +8,12 @@
|
|||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<Compile Include="Utils.fs" />
|
||||||
<Compile Include="LonghandExample.fs"/>
|
<Compile Include="LonghandExample.fs"/>
|
||||||
<Compile Include="Tests.fs" />
|
<Compile Include="Tests.fs" />
|
||||||
|
<Compile Include="TestJson.fs" />
|
||||||
|
<Compile Include="TestEndToEnd.fs" />
|
||||||
|
<EmbeddedResource Include="example1.json" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
@@ -22,7 +26,7 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\AnkiStatic\AnkiStatic.fsproj"/>
|
<ProjectReference Include="..\AnkiStatic.Lib\AnkiStatic.Lib.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
@@ -1,9 +1,7 @@
|
|||||||
namespace AnkiStatic.Test
|
namespace AnkiStatic.Test
|
||||||
|
|
||||||
open System
|
open System
|
||||||
open System.Collections.Generic
|
|
||||||
open System.IO
|
open System.IO
|
||||||
open System.IO.Compression
|
|
||||||
open AnkiStatic
|
open AnkiStatic
|
||||||
open NUnit.Framework
|
open NUnit.Framework
|
||||||
|
|
||||||
@@ -86,7 +84,7 @@ module Example =
|
|||||||
SortField = frontField
|
SortField = frontField
|
||||||
Templates = [ frontTemplate ; backTemplate ]
|
Templates = [ frontTemplate ; backTemplate ]
|
||||||
Type = ModelType.Standard
|
Type = ModelType.Standard
|
||||||
Deck = deck
|
DefaultDeck = deck
|
||||||
}
|
}
|
||||||
|
|
||||||
let textField : SerialisedModelField =
|
let textField : SerialisedModelField =
|
||||||
@@ -128,7 +126,7 @@ module Example =
|
|||||||
SortField = textField
|
SortField = textField
|
||||||
Templates = [ clozeTemplate ]
|
Templates = [ clozeTemplate ]
|
||||||
Type = ModelType.Cloze
|
Type = ModelType.Cloze
|
||||||
Deck = deck
|
DefaultDeck = deck
|
||||||
}
|
}
|
||||||
|
|
||||||
let example : SerialisedCollection =
|
let example : SerialisedCollection =
|
||||||
@@ -176,7 +174,7 @@ module Example =
|
|||||||
EasinessPerEasyReview = 1.3
|
EasinessPerEasyReview = 1.3
|
||||||
Fuzz = 0.05
|
Fuzz = 0.05
|
||||||
IntervalFactor = 1
|
IntervalFactor = 1
|
||||||
MaxInterval = TimeSpan.FromDays 365.0
|
MaxInterval = TimeSpan.FromDays 36500.0
|
||||||
PerDay = 100
|
PerDay = 100
|
||||||
}
|
}
|
||||||
ShowTimer = false
|
ShowTimer = false
|
||||||
@@ -200,6 +198,7 @@ module Example =
|
|||||||
ValueOfSortField = "Definition of the logistic function"
|
ValueOfSortField = "Definition of the logistic function"
|
||||||
ValuesOfAdditionalFields = [ @"\(g(z) = \frac{1}{1+e^{-z}}\)" ]
|
ValuesOfAdditionalFields = [ @"\(g(z) = \frac{1}{1+e^{-z}}\)" ]
|
||||||
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
||||||
|
Deck = deck
|
||||||
}
|
}
|
||||||
{
|
{
|
||||||
Model = clozeModel
|
Model = clozeModel
|
||||||
@@ -208,67 +207,15 @@ module Example =
|
|||||||
"The four perspectives of Ithkuil are {{c1::monadic}}, {{c2::unbounded}}, {{c3::nomic}}, {{c4::abstract}}."
|
"The four perspectives of Ithkuil are {{c1::monadic}}, {{c2::unbounded}}, {{c3::nomic}}, {{c4::abstract}}."
|
||||||
ValuesOfAdditionalFields = [ "" ]
|
ValuesOfAdditionalFields = [ "" ]
|
||||||
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
CreationDate = DateTimeOffset (2023, 09, 06, 19, 30, 00, TimeSpan.FromHours 1.0)
|
||||||
|
Deck = deck
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
||||||
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 =
|
let outputFile =
|
||||||
Path.GetTempFileName ()
|
Path.GetTempFileName ()
|
||||||
|> fun f -> Path.ChangeExtension (f, ".apkg")
|
|> fun f -> Path.ChangeExtension (f, ".apkg")
|
||||||
|> FileInfo
|
|> FileInfo
|
||||||
|
|
||||||
use outputStream = outputFile.OpenWrite ()
|
Sqlite.writeAll (Random 1) collection notes outputFile |> fun t -> t.Result
|
||||||
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}"
|
Console.WriteLine $"Written: %s{outputFile.FullName}"
|
||||||
return ()
|
|
||||||
}
|
|
||||||
|
30
AnkiStatic.Test/TestEndToEnd.fs
Normal file
30
AnkiStatic.Test/TestEndToEnd.fs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
namespace AnkiStatic.Test
|
||||||
|
|
||||||
|
open AnkiStatic
|
||||||
|
open NUnit.Framework
|
||||||
|
open System
|
||||||
|
open System.IO
|
||||||
|
|
||||||
|
[<TestFixture>]
|
||||||
|
module TestEndToEnd =
|
||||||
|
type private Dummy =
|
||||||
|
class
|
||||||
|
end
|
||||||
|
|
||||||
|
[<TestCase "example1.json">]
|
||||||
|
let ``End-to-end test of example1.json`` (fileName : string) =
|
||||||
|
let assembly = typeof<Dummy>.Assembly
|
||||||
|
let json = Utils.readResource assembly fileName
|
||||||
|
|
||||||
|
let collection, notes = JsonCollection.deserialise json |> JsonCollection.toInternal
|
||||||
|
|
||||||
|
let outputFile =
|
||||||
|
Path.GetTempFileName ()
|
||||||
|
|> fun f -> Path.ChangeExtension (f, ".apkg")
|
||||||
|
|> FileInfo
|
||||||
|
|
||||||
|
let collection = SerialisedCollection.toSqlite collection
|
||||||
|
|
||||||
|
Sqlite.writeAll (Random 1) collection notes outputFile |> fun t -> t.Result
|
||||||
|
|
||||||
|
Console.WriteLine $"Written file: %s{outputFile.FullName}"
|
20
AnkiStatic.Test/TestJson.fs
Normal file
20
AnkiStatic.Test/TestJson.fs
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
namespace AnkiStatic.Test
|
||||||
|
|
||||||
|
open NUnit.Framework
|
||||||
|
open AnkiStatic
|
||||||
|
open FsUnitTyped
|
||||||
|
|
||||||
|
[<TestFixture>]
|
||||||
|
module TestJson =
|
||||||
|
|
||||||
|
type private Dummy =
|
||||||
|
class
|
||||||
|
end
|
||||||
|
|
||||||
|
[<Test>]
|
||||||
|
let ``Can read example`` () =
|
||||||
|
let assembly = typeof<Dummy>.Assembly
|
||||||
|
let json = Utils.readResource assembly "example1.json"
|
||||||
|
|
||||||
|
let collection, notes = JsonCollection.deserialise json |> JsonCollection.toInternal
|
||||||
|
()
|
21
AnkiStatic.Test/Utils.fs
Normal file
21
AnkiStatic.Test/Utils.fs
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
namespace AnkiStatic.Test
|
||||||
|
|
||||||
|
open System
|
||||||
|
open System.IO
|
||||||
|
open System.Reflection
|
||||||
|
|
||||||
|
[<RequireQualifiedAccess>]
|
||||||
|
module Utils =
|
||||||
|
|
||||||
|
let readResource (assembly : Assembly) (name : string) : string =
|
||||||
|
let names =
|
||||||
|
assembly.GetManifestResourceNames ()
|
||||||
|
|> Seq.filter (fun s -> s.EndsWith (name, StringComparison.Ordinal))
|
||||||
|
|
||||||
|
use stream =
|
||||||
|
names
|
||||||
|
|> Seq.exactlyOne
|
||||||
|
|> assembly.GetManifestResourceStream
|
||||||
|
|> fun s -> new StreamReader (s)
|
||||||
|
|
||||||
|
stream.ReadToEnd ()
|
171
AnkiStatic.Test/example1.json
Normal file
171
AnkiStatic.Test/example1.json
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
{
|
||||||
|
"metadata": {
|
||||||
|
"creationDate": "2023-09-06T19:30:00+01:00",
|
||||||
|
"collapseTimeSeconds": 1200,
|
||||||
|
"estimateTimes": false,
|
||||||
|
"newSpread": "distribute",
|
||||||
|
"showDueCounts": true,
|
||||||
|
"sortBackwards": false,
|
||||||
|
"defaultDeck": "Analysis",
|
||||||
|
"defaultDeckConfiguration": {
|
||||||
|
"autoPlay": true,
|
||||||
|
"lapse": {
|
||||||
|
"delays": [
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"leechAction": "suspend",
|
||||||
|
"leechFails": 8,
|
||||||
|
"minInterval": 1,
|
||||||
|
"multiplier": 0
|
||||||
|
},
|
||||||
|
"name": "Default",
|
||||||
|
"new": {
|
||||||
|
"delays": [
|
||||||
|
1,
|
||||||
|
10
|
||||||
|
],
|
||||||
|
"initialEase": 2500,
|
||||||
|
"intervals": {
|
||||||
|
"good": 1,
|
||||||
|
"easy": 4,
|
||||||
|
"unused": 7
|
||||||
|
},
|
||||||
|
"order": "random",
|
||||||
|
"maxNewPerDay": 20
|
||||||
|
},
|
||||||
|
"replayQuestionAudioWithAnswer": true,
|
||||||
|
"review": {
|
||||||
|
"easinessPerEasyReview": 1.3,
|
||||||
|
"fuzz": 0.05,
|
||||||
|
"intervalFactor": 1,
|
||||||
|
"maxIntervalDays": 36500,
|
||||||
|
"perDay": 100
|
||||||
|
},
|
||||||
|
"showTimer": false,
|
||||||
|
"maxTimerTimeoutSeconds": 60
|
||||||
|
},
|
||||||
|
"nonDefaultDecks": {},
|
||||||
|
"nonDefaultDeckConfigurations": {},
|
||||||
|
"tags": "{}",
|
||||||
|
"defaultModelName": "basicAndReverse"
|
||||||
|
},
|
||||||
|
"standardTemplates": {
|
||||||
|
"front": {
|
||||||
|
"answerFormat": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Back}}",
|
||||||
|
"browserAnswerFormat": "",
|
||||||
|
"browserQuestionFormat": "",
|
||||||
|
"name": "Card 1",
|
||||||
|
"questionFormat": "{{Front}}"
|
||||||
|
},
|
||||||
|
"back": {
|
||||||
|
"answerFormat": "{{FrontSide}}\n\n<hr id=answer>\n\n{{Front}}",
|
||||||
|
"browserAnswerFormat": "",
|
||||||
|
"browserQuestionFormat": "",
|
||||||
|
"name": "Card 2",
|
||||||
|
"questionFormat": "{{Back}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"clozeTemplates": {
|
||||||
|
"clozeTemplate": {
|
||||||
|
"answerFormat": "{{cloze:Text}}<br>\n{{Extra}}",
|
||||||
|
"browserAnswerFormat": "",
|
||||||
|
"browserQuestionFormat": "",
|
||||||
|
"name": "Cloze",
|
||||||
|
"questionFormat": "{{cloze:Text}}"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"decks": {
|
||||||
|
"Analysis": {
|
||||||
|
"extendedReviewLimit": 50,
|
||||||
|
"extendedNewCardLimit": 10,
|
||||||
|
"collapsed": false,
|
||||||
|
"browserCollapsed": false,
|
||||||
|
"description": "",
|
||||||
|
"notes": [
|
||||||
|
{
|
||||||
|
"tags": [],
|
||||||
|
"sortFieldValue": "Definition of the logistic function",
|
||||||
|
"additionalFieldValues": [
|
||||||
|
"\\(g(z) = \\frac{1}{1+e^{-z}}\\)"
|
||||||
|
],
|
||||||
|
"creationDate": "2023-09-06T19:30:00+01:00",
|
||||||
|
"model": "basicAndReverse"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"tags": [],
|
||||||
|
"sortFieldValue": "The four perspectives of Ithkuil are {{c1::monadic}}, {{c2::unbounded}}, {{c3::nomic}}, {{c4::abstract}}.",
|
||||||
|
"additionalFieldValues": [
|
||||||
|
""
|
||||||
|
],
|
||||||
|
"creationDate": "2023-09-06T19:30:00.001+01:00",
|
||||||
|
"model": "cloze"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"frontField": {
|
||||||
|
"font": "Arial",
|
||||||
|
"displayName": "Front",
|
||||||
|
"rightToLeft": false,
|
||||||
|
"fontSize": 20,
|
||||||
|
"sticky": false
|
||||||
|
},
|
||||||
|
"backField": {
|
||||||
|
"displayName": "Back",
|
||||||
|
"font": "Arial",
|
||||||
|
"rightToLeft": false,
|
||||||
|
"fontSize": 20,
|
||||||
|
"sticky": false
|
||||||
|
},
|
||||||
|
"text": {
|
||||||
|
"displayName": "Text",
|
||||||
|
"font": "Arial",
|
||||||
|
"rightToLeft": false,
|
||||||
|
"fontSize": 20,
|
||||||
|
"sticky": false
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"displayName": "Extra",
|
||||||
|
"font": "Arial",
|
||||||
|
"rightToLeft": false,
|
||||||
|
"fontSize": 20,
|
||||||
|
"sticky": false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"cloze": {
|
||||||
|
"modificationTime": "2013-07-10T17:17:08.440+01:00",
|
||||||
|
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n\n.cloze {\n font-weight: bold;\n color: blue;\n}",
|
||||||
|
"sortField": "text",
|
||||||
|
"additionalFields": [
|
||||||
|
"extra"
|
||||||
|
],
|
||||||
|
"latexPost": "\\end{document}",
|
||||||
|
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||||
|
"name": "Cloze",
|
||||||
|
"templates": [
|
||||||
|
"clozeTemplate"
|
||||||
|
],
|
||||||
|
"type": "cloze",
|
||||||
|
"defaultDeck": "Analysis"
|
||||||
|
},
|
||||||
|
"basicAndReverse": {
|
||||||
|
"modificationTime": "2013-07-10T17:17:08.445+01:00",
|
||||||
|
"css": ".card {\n font-family: arial;\n font-size: 20px;\n text-align: center;\n color: black;\n background-color: white;\n}\n",
|
||||||
|
"sortField": "frontField",
|
||||||
|
"additionalFields": [
|
||||||
|
"backField"
|
||||||
|
],
|
||||||
|
"latexPost": "\\end{document}",
|
||||||
|
"latexPre": "\\documentclass[12pt]{article}\n\\special{papersize=3in,5in}\n\\usepackage[utf8]{inputenc}\n\\usepackage{amssymb,amsmath}\n\\pagestyle{empty}\n\\setlength{\\parindent}{0in}\n\\begin{document}\n",
|
||||||
|
"name": "Basic (and reversed card)",
|
||||||
|
"templates": [
|
||||||
|
"front",
|
||||||
|
"back"
|
||||||
|
],
|
||||||
|
"type": "standard",
|
||||||
|
"defaultDeck": "Analysis"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@@ -1,9 +1,11 @@
|
|||||||
|
|
||||||
Microsoft Visual Studio Solution File, Format Version 12.00
|
Microsoft Visual Studio Solution File, Format Version 12.00
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic", "AnkiStatic\AnkiStatic.fsproj", "{74D45DA0-912E-45B0-9832-EAE763493431}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic.Lib", "AnkiStatic.Lib\AnkiStatic.Lib.fsproj", "{74D45DA0-912E-45B0-9832-EAE763493431}"
|
||||||
EndProject
|
EndProject
|
||||||
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic.Test", "AnkiStatic.Test\AnkiStatic.Test.fsproj", "{042891EC-592B-443D-B5EA-847AE1FA9E2B}"
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic.Test", "AnkiStatic.Test\AnkiStatic.Test.fsproj", "{042891EC-592B-443D-B5EA-847AE1FA9E2B}"
|
||||||
EndProject
|
EndProject
|
||||||
|
Project("{F2A71F9B-5D33-465A-A702-920D77279786}") = "AnkiStatic", "AnkiStatic\AnkiStatic.fsproj", "{819FF90C-F165-4E9B-96E9-A5BBDA80261E}"
|
||||||
|
EndProject
|
||||||
Global
|
Global
|
||||||
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
GlobalSection(SolutionConfigurationPlatforms) = preSolution
|
||||||
Debug|Any CPU = Debug|Any CPU
|
Debug|Any CPU = Debug|Any CPU
|
||||||
@@ -18,5 +20,9 @@ Global
|
|||||||
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.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.ActiveCfg = Release|Any CPU
|
||||||
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Release|Any CPU.Build.0 = Release|Any CPU
|
{042891EC-592B-443D-B5EA-847AE1FA9E2B}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
|
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
|
||||||
|
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Debug|Any CPU.Build.0 = Debug|Any CPU
|
||||||
|
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Release|Any CPU.ActiveCfg = Release|Any CPU
|
||||||
|
{819FF90C-F165-4E9B-96E9-A5BBDA80261E}.Release|Any CPU.Build.0 = Release|Any CPU
|
||||||
EndGlobalSection
|
EndGlobalSection
|
||||||
EndGlobal
|
EndGlobal
|
||||||
|
@@ -5,30 +5,13 @@
|
|||||||
<TargetFramework>net7.0</TargetFramework>
|
<TargetFramework>net7.0</TargetFramework>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
|
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<Compile Include="Domain\Deck.fs"/>
|
<Compile Include="Program.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>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.Data.SQLite" Version="7.0.10"/>
|
<ProjectReference Include="..\AnkiStatic.Lib\AnkiStatic.Lib.fsproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
53
hooks/format.py
Executable file
53
hooks/format.py
Executable file
@@ -0,0 +1,53 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
# Returns False if the file was changed, True if it didn't need changing,
|
||||||
|
# and None if the file was inaccessible for some reason.
|
||||||
|
def format_json_file(filepath, check_only=False):
|
||||||
|
try:
|
||||||
|
with open(filepath, 'r') as f:
|
||||||
|
original_data = f.read()
|
||||||
|
parsed_data = json.loads(original_data)
|
||||||
|
except FileNotFoundError:
|
||||||
|
print(f"File {filepath} not found.")
|
||||||
|
return None
|
||||||
|
except json.JSONDecodeError:
|
||||||
|
print(f"File {filepath} contains invalid JSON.")
|
||||||
|
return None
|
||||||
|
|
||||||
|
formatted_data = json.dumps(parsed_data, indent=2)
|
||||||
|
|
||||||
|
if check_only:
|
||||||
|
return original_data == formatted_data
|
||||||
|
|
||||||
|
if original_data != formatted_data:
|
||||||
|
with tempfile.NamedTemporaryFile('w', delete=False) as temp:
|
||||||
|
temp.write(formatted_data)
|
||||||
|
temp_filename = temp.name
|
||||||
|
|
||||||
|
os.rename(temp_filename, filepath)
|
||||||
|
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == '__main__':
|
||||||
|
import sys
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(description="Format JSON files.")
|
||||||
|
parser.add_argument("filepaths", nargs='+', help="Path to JSON files to format")
|
||||||
|
parser.add_argument("--check", action="store_true", help="Only check if files are correctly formatted")
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
all_files_ok = True
|
||||||
|
|
||||||
|
for filepath in args.filepaths:
|
||||||
|
success = format_json_file(filepath, args.check)
|
||||||
|
if not success:
|
||||||
|
all_files_ok = False
|
||||||
|
|
||||||
|
sys.exit(0 if all_files_ok else 1)
|
@@ -1,6 +1,10 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
|
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import sys, os
|
||||||
|
current_directory = os.path.dirname(os.path.abspath(__file__))
|
||||||
|
sys.path.insert(0, current_directory)
|
||||||
|
from format import format_json_file
|
||||||
|
|
||||||
def check_fantomas():
|
def check_fantomas():
|
||||||
result = subprocess.run(["dotnet", "tool", "run", "fantomas", "--check", "."])
|
result = subprocess.run(["dotnet", "tool", "run", "fantomas", "--check", "."])
|
||||||
@@ -16,9 +20,26 @@ def check_alejandra():
|
|||||||
raise Exception(f"Formatting incomplete (return code: {result.returncode}). Consider running `alejandra *.nix`")
|
raise Exception(f"Formatting incomplete (return code: {result.returncode}). Consider running `alejandra *.nix`")
|
||||||
|
|
||||||
|
|
||||||
|
def check_json():
|
||||||
|
all_ok = True
|
||||||
|
for root, _, files in os.walk(os.getcwd()):
|
||||||
|
for file in files:
|
||||||
|
# NuGet outputs invalid JSON files :facepalm:
|
||||||
|
if file.endswith(".json") and not file.endswith("project.packagespec.json"):
|
||||||
|
full_path = os.path.join(root, file)
|
||||||
|
is_formatted = format_json_file(full_path, check_only = True)
|
||||||
|
if not is_formatted:
|
||||||
|
print(f"File {full_path} is not formatted")
|
||||||
|
all_ok = all_ok and is_formatted
|
||||||
|
|
||||||
|
if not all_ok:
|
||||||
|
raise Exception(f"Formatting incomplete in JSON files. Consider running `find . -type f -name '*.json' | grep -v 'packagespec.json' | xargs hooks/format.py`")
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
check_fantomas()
|
check_fantomas()
|
||||||
check_alejandra()
|
check_alejandra()
|
||||||
|
check_json()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
Reference in New Issue
Block a user