deck config prototype work in progress

Still in the early stages, and not hooked up yet.
This commit is contained in:
Damien Elmes 2021-04-12 14:18:30 +10:00
parent e6306bb29d
commit 7f738c11a2
32 changed files with 1133 additions and 27 deletions

View File

@ -22,6 +22,7 @@ DeckTreeNode = _pb.DeckTreeNode
DeckNameId = _pb.DeckNameId
FilteredDeckConfig = _pb.Deck.Filtered
DeckCollapseScope = _pb.SetDeckCollapsedIn.Scope
DeckConfigForUpdate = _pb.DeckConfigForUpdate
# legacy code may pass this in as the type argument to .id()
defaultDeck = 0
@ -324,6 +325,9 @@ class DeckManager:
# Deck configurations
#############################################################
def get_deck_config_for_update(self, deck_id: DeckId) -> DeckConfigForUpdate:
return self.col._backend.get_deck_config_for_update(deck_id)
def all_config(self) -> List[DeckConfigDict]:
"A list of all deck config."
return list(from_json_bytes(self.col._backend.all_deck_config_legacy()))

View File

@ -1,32 +1,24 @@
load("//ts:copy.bzl", "copy_files_into_group")
copy_files_into_group(
name = "graphs_page",
srcs = [
"graphs-base.css",
"graphs.css",
"graphs.html",
"graphs.js",
],
package = "//ts/graphs",
)
_pages = [
"graphs",
"congrats",
"deckconfig",
]
copy_files_into_group(
name = "congrats_page",
[copy_files_into_group(
name = name + "_page",
srcs = [
"congrats-base.css",
"congrats.css",
"congrats.html",
"congrats.js",
name + "-base.css",
name + ".css",
name + ".html",
name + ".js",
],
package = "//ts/congrats",
)
package = "//ts/" + name,
) for name in _pages]
filegroup(
name = "pages",
srcs = [
"congrats_page",
"graphs_page",
],
srcs = [name + "_page" for name in _pages],
visibility = ["//qt:__subpackages__"],
)

View File

@ -274,10 +274,18 @@ def i18n_resources() -> bytes:
return aqt.mw.col.i18n_resources(modules=args["modules"])
def deck_config_for_update() -> bytes:
args = from_json_bytes(request.data)
return aqt.mw.col.decks.get_deck_config_for_update(
deck_id=args["deckId"]
).SerializeToString()
post_handlers = {
"graphData": graph_data,
"graphPreferences": graph_preferences,
"setGraphPreferences": set_graph_preferences,
"deckConfigForUpdate": deck_config_for_update,
# pylint: disable=unnecessary-lambda
"i18nResources": i18n_resources,
"congratsInfo": congrats_info,

View File

@ -228,6 +228,8 @@ service DeckConfigService {
rpc GetDeckConfigLegacy(DeckConfigId) returns (Json);
rpc NewDeckConfigLegacy(Empty) returns (Json);
rpc RemoveDeckConfig(DeckConfigId) returns (Empty);
rpc GetDeckConfigForUpdate(DeckId) returns (DeckConfigForUpdate);
rpc UpdateDeckConfig(UpdateDeckConfigIn) returns (OpChanges);
}
service TagsService {
@ -893,6 +895,30 @@ message AddOrUpdateDeckConfigLegacyIn {
bool preserve_usn_and_mtime = 2;
}
message DeckConfigForUpdate {
message ConfigWithExtra {
DeckConfig config = 1;
uint32 use_count = 2;
}
message CurrentDeck {
string name = 1;
int64 config_id = 2;
uint32 parent_new_limit = 3;
uint32 parent_review_limit = 4;
}
repeated ConfigWithExtra all_config = 1;
CurrentDeck current_deck = 2;
DeckConfig defaults = 3;
}
message UpdateDeckConfigIn {
int64 target_deck_id = 2;
DeckConfig desired_config = 3;
repeated int64 removed_config_ids = 4;
bool apply_to_children = 5;
}
message SetTagCollapsedIn {
string name = 1;
bool collapsed = 2;

View File

@ -61,6 +61,14 @@ impl DeckConfigService for Backend {
self.with_col(|col| col.transact_no_undo(|col| col.remove_deck_config(input.into())))
.map(Into::into)
}
fn get_deck_config_for_update(&self, input: pb::DeckId) -> Result<pb::DeckConfigForUpdate> {
self.with_col(|col| col.get_deck_config_for_update(input.into()))
}
fn update_deck_config(&self, _input: pb::UpdateDeckConfigIn) -> Result<pb::OpChanges> {
todo!();
}
}
impl From<DeckConf> for pb::DeckConfig {

View File

@ -1,6 +1,9 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod schema11;
mod update;
use crate::{
collection::Collection,
define_newtype,
@ -18,8 +21,6 @@ pub use schema11::{DeckConfSchema11, NewCardOrderSchema11};
/// Old deck config and cards table store 250% as 2500.
pub(crate) const INITIAL_EASE_FACTOR_THOUSANDS: u16 = (INITIAL_EASE_FACTOR * 1000.0) as u16;
mod schema11;
define_newtype!(DeckConfId, i64);
#[derive(Debug, PartialEq, Clone)]

View File

@ -0,0 +1,82 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use std::collections::{HashMap, HashSet};
use pb::deck_config_for_update::{ConfigWithExtra, CurrentDeck};
use crate::{backend_proto as pb, prelude::*};
impl Collection {
/// Information required for the deck options screen.
pub fn get_deck_config_for_update(&mut self, deck: DeckId) -> Result<pb::DeckConfigForUpdate> {
Ok(pb::DeckConfigForUpdate {
all_config: self.get_deck_config_with_extra_for_update()?,
current_deck: Some(self.get_current_deck_for_update(deck)?),
defaults: Some(DeckConf::default().into()),
})
}
}
impl Collection {
fn get_deck_config_with_extra_for_update(&self) -> Result<Vec<ConfigWithExtra>> {
// grab the config and sort it
let mut config = self.storage.all_deck_config()?;
config.sort_unstable_by(|a, b| a.name.cmp(&b.name));
// combine with use counts
let counts = self.get_deck_config_use_counts()?;
Ok(config
.into_iter()
.map(|config| ConfigWithExtra {
use_count: counts.get(&config.id).cloned().unwrap_or_default() as u32,
config: Some(config.into()),
})
.collect())
}
fn get_deck_config_use_counts(&self) -> Result<HashMap<DeckConfId, usize>> {
let mut counts = HashMap::new();
for deck in self.storage.get_all_decks()? {
if let Ok(normal) = deck.normal() {
*counts.entry(DeckConfId(normal.config_id)).or_default() += 1;
}
}
Ok(counts)
}
fn get_current_deck_for_update(&mut self, deck: DeckId) -> Result<CurrentDeck> {
let deck = self.get_deck(deck)?.ok_or(AnkiError::NotFound)?;
let mut parent_new_limit = u32::MAX;
let mut parent_review_limit = u32::MAX;
for config_id in self.parent_config_ids(&deck)? {
if let Some(config) = self.storage.get_deck_config(config_id)? {
parent_new_limit = parent_new_limit.min(config.inner.new_per_day);
parent_review_limit = parent_review_limit.min(config.inner.reviews_per_day);
}
}
Ok(CurrentDeck {
name: deck.name.clone(),
config_id: deck.normal()?.config_id,
parent_new_limit,
parent_review_limit,
})
}
/// Deck configs used by parent decks.
fn parent_config_ids(&self, deck: &Deck) -> Result<HashSet<DeckConfId>> {
Ok(self
.storage
.parent_decks(deck)?
.iter()
.filter_map(|deck| {
deck.normal()
.ok()
.map(|normal| DeckConfId(normal.config_id))
})
.collect())
}
}

116
ts/deckconfig/BUILD.bazel Normal file
View File

@ -0,0 +1,116 @@
load("@npm//@bazel/typescript:index.bzl", "ts_library")
load("//ts:prettier.bzl", "prettier_test")
load("//ts:eslint.bzl", "eslint_test")
load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte", "svelte_check")
load("//ts:esbuild.bzl", "esbuild")
load("//ts:vendor.bzl", "copy_bootstrap_icons")
load("//ts:compile_sass.bzl", "compile_sass")
compile_sass(
srcs = ["deckconfig-base.scss"],
group = "base_css",
visibility = ["//visibility:public"],
deps = [
"//ts/bootstrap:scss",
"//ts/sass:base_lib",
"//ts/sass:scrollbar_lib",
],
)
svelte_files = glob(["*.svelte"])
svelte_names = [f.replace(".svelte", "") for f in svelte_files]
compile_svelte(
name = "svelte",
srcs = svelte_files,
)
copy_bootstrap_icons(
name = "bootstrap-icons",
icons = [
"arrow-counterclockwise.svg",
],
)
ts_library(
name = "index",
srcs = ["index.ts"],
deps = [
"DeckConfigPage",
"lib",
"//ts/lib",
"@npm//svelte2tsx",
],
)
ts_library(
name = "lib",
srcs = [
"icons.ts",
"lib.ts",
"steps.ts",
],
module_name = "deckconfig",
deps = [
"//ts:image_module_support",
"//ts/lib",
"//ts/lib:backend_proto",
],
)
esbuild(
name = "deckconfig",
srcs = [
"//ts:protobuf-shim.js",
],
args = [
"--global-name=anki",
"--inject:$(location //ts:protobuf-shim.js)",
"--resolve-extensions=.mjs,.js",
"--log-level=warning",
"--loader:.svg=text",
],
entry_point = "index.ts",
external = [
"protobufjs/light",
],
output_css = True,
visibility = ["//visibility:public"],
deps = [
"index",
"//ts/lib",
"//ts/lib:backend_proto",
":bootstrap-icons",
"@npm//bootstrap",
":base_css",
] + svelte_names,
)
exports_files(["deckconfig.html"])
# Tests
################
prettier_test(
name = "format_check",
srcs = glob([
"*.ts",
"*.svelte",
]),
)
eslint_test(
name = "eslint",
srcs = glob([
"*.ts",
]),
)
svelte_check(
name = "svelte_check",
srcs = glob([
"*.ts",
"*.svelte",
]),
)

View File

@ -0,0 +1,19 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ConfigEntry from "./ConfigEntry.svelte";
export let label: string;
export let subLabel: string;
export let value: boolean;
export let defaultValue: boolean;
</script>
<style>
</style>
<ConfigEntry {label} bind:value {defaultValue}>
<label> <input type="checkbox" bind:checked={value} /> {subLabel} </label>
</ConfigEntry>

View File

@ -0,0 +1,31 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import NewOptions from "./NewOptions.svelte";
import ReviewOptions from "./ReviewOptions.svelte";
import LapseOptions from "./LapseOptions.svelte";
import GeneralOptions from "./GeneralOptions.svelte";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
</script>
<style lang="scss">
:global(h2) {
margin-top: 1em;
}
:global(.warn) {
background-color: var(--flag1-bg);
}
</style>
<div>
<NewOptions bind:config {defaults} />
<ReviewOptions bind:config {defaults} />
<LapseOptions bind:config {defaults} />
<GeneralOptions bind:config {defaults} />
</div>

View File

@ -0,0 +1,36 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import RevertIcon from "./RevertIcon.svelte";
export let label: string;
export let subLabel = "";
export let value: any;
export let defaultValue: any;
</script>
<style lang="scss">
.outer {
margin-top: 1em;
& > * {
margin-top: 0.3em;
}
}
.label {
font-weight: bold;
}
</style>
<div class="outer">
<span class="label">{label}</span>
<RevertIcon bind:value {defaultValue} on:revert />
<div class="sub">{subLabel}</div>
<div class="inner">
<slot />
</div>
</div>

View File

@ -0,0 +1,58 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import * as tr from "anki/i18n";
import type { DeckConfigId, ConfigWithCount } from "./lib";
import OptionsDropdown from "./OptionsDropdown.svelte";
export let allConfig: ConfigWithCount[];
export let selectedConfigId: DeckConfigId;
function configLabel(config: ConfigWithCount): string {
const name = config.config.name;
const count = tr.deckConfigUsedByDecks({ decks: config.useCount });
return `${name} (${count})`;
}
</script>
<style lang="scss">
.form-select {
display: inline-block;
width: 30em;
}
.outer {
position: fixed;
z-index: 1;
top: 0;
left: 0;
width: 100%;
color: var(--text-fg);
background: var(--window-bg);
padding: 0.5em;
}
.inner {
display: flex;
justify-content: center;
& > :global(*) {
padding-left: 0.5em;
padding-right: 0.5em;
}
}
</style>
<div class="outer">
<div class="inner">
<select bind:value={selectedConfigId} class="form-select">
{#each allConfig as config}
<option value={config.config.id}>{configLabel(config)}</option>
{/each}
</select>
<OptionsDropdown />
</div>
</div>

View File

@ -0,0 +1,49 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import ConfigSelector from "./ConfigSelector.svelte";
import ConfigEditor from "./ConfigEditor.svelte";
import * as tr from "anki/i18n";
import type { DeckConfigState } from "./lib";
export let state: DeckConfigState;
let selectedConfigId = state.selectedConfigId;
let selectedConfig: pb.BackendProto.DeckConfig.Config;
$: {
selectedConfig = (
state.allConfigs.find((e) => e.config.id == selectedConfigId)?.config ??
state.allConfigs[0].config
).config as pb.BackendProto.DeckConfig.Config;
}
let defaults = state.defaults;
</script>
<style>
.outer {
display: flex;
justify-content: center;
}
.inner {
padding: 0.5em;
}
:global(input, select) {
font-size: 16px;
}
</style>
<div class="outer">
<div class="inner">
<div><b>{tr.actionsOptionsFor({ val: state.deckName })}</b></div>
<ConfigSelector allConfig={state.allConfigs} bind:selectedConfigId />
<ConfigEditor config={selectedConfig} {defaults} />
</div>
</div>

View File

@ -0,0 +1,21 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ConfigEntry from "./ConfigEntry.svelte";
export let label: string;
export let subLabel: string;
export let choices: string[];
export let value: number = 0;
export let defaultValue: number;
</script>
<ConfigEntry {label} {subLabel} bind:value {defaultValue}>
<select bind:value class="form-select">
{#each choices as choice, idx}
<option value={idx}>{choice}</option>
{/each}
</select>
</ConfigEntry>

View File

@ -0,0 +1,43 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import CheckBox from "./CheckBox.svelte";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
</script>
<div>
<h2>General</h2>
<SpinBox
label={tr.schedulingIgnoreAnswerTimesLongerThan()}
subLabel="The maximum number of seconds to record for a single review."
min={30}
max={600}
defaultValue={defaults.capAnswerTimeToSecs}
bind:value={config.capAnswerTimeToSecs} />
<CheckBox
label="Answer timer"
subLabel={tr.schedulingShowAnswerTimer()}
defaultValue={defaults.showTimer}
bind:value={config.showTimer} />
<CheckBox
label="Autoplay"
subLabel="Don't play audio automatically"
defaultValue={defaults.disableAutoplay}
bind:value={config.disableAutoplay} />
<CheckBox
label="Question Audio"
subLabel={tr.schedulingAlwaysIncludeQuestionSideWhenReplaying()}
defaultValue={defaults.skipQuestionWhenReplayingAnswer}
bind:value={config.skipQuestionWhenReplayingAnswer} />
</div>

View File

@ -0,0 +1,58 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
const leechChoices = [tr.actionsSuspendCard(), tr.schedulingTagOnly()];
</script>
<div>
<h2>Lapses</h2>
<StepsInput
label="Relearning steps"
subLabel="Relearning steps, separated by spaces."
defaultValue={defaults.relearnSteps}
value={config.relearnSteps}
on:changed={(evt) => (config.relearnSteps = evt.detail.value)} />
<SpinBoxFloat
label={tr.schedulingNewInterval()}
subLabel="The multiplier applied to review cards when answering Again."
min={0}
max={1}
defaultValue={defaults.lapseMultiplier}
value={config.lapseMultiplier}
on:changed={(evt) => (config.lapseMultiplier = evt.detail.value)} />
<SpinBox
label={tr.schedulingMinimumInterval()}
subLabel="The minimum new interval a lapsed card will be given after relearning."
min={1}
defaultValue={defaults.minimumLapseInterval}
bind:value={config.minimumLapseInterval} />
<SpinBox
label={tr.schedulingLeechThreshold()}
subLabel="Number of times Again needs to be pressed on a review card to make it a leech."
min={1}
defaultValue={defaults.leechThreshold}
bind:value={config.leechThreshold} />
<EnumSelector
label={tr.schedulingLeechAction()}
subLabel=""
choices={leechChoices}
defaultValue={defaults.leechAction}
bind:value={config.leechAction} />
</div>

View File

@ -0,0 +1,88 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte";
import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
const newOrderChoices = [
tr.schedulingShowNewCardsInOrderAdded(),
tr.schedulingShowNewCardsInRandomOrder(),
];
let stepsExceedGraduatingInterval: boolean;
$: {
const lastLearnStepInDays = config.learnSteps.length
? config.learnSteps[config.learnSteps.length - 1] / 60 / 24
: 0;
stepsExceedGraduatingInterval =
lastLearnStepInDays > config.graduatingIntervalGood;
}
let goodExceedsEasy: boolean;
$: goodExceedsEasy = config.graduatingIntervalGood > config.graduatingIntervalEasy;
</script>
<div>
<h2>New Cards</h2>
<StepsInput
label="Learning steps"
subLabel="Learning steps, separated by spaces."
warn={stepsExceedGraduatingInterval}
defaultValue={defaults.learnSteps}
value={config.learnSteps}
on:changed={(evt) => (config.learnSteps = evt.detail.value)} />
<EnumSelector
label={tr.schedulingOrder()}
subLabel=""
choices={newOrderChoices}
defaultValue={defaults.newCardOrder}
bind:value={config.newCardOrder} />
<SpinBox
label={tr.schedulingNewCardsday()}
subLabel="The maximum number of new cards to introduce in a day."
min={0}
defaultValue={defaults.newPerDay}
bind:value={config.newPerDay} />
<SpinBox
label={tr.schedulingGraduatingInterval()}
subLabel="Days to wait after answering Good on the last learning step."
warn={stepsExceedGraduatingInterval || goodExceedsEasy}
defaultValue={defaults.graduatingIntervalGood}
bind:value={config.graduatingIntervalGood} />
<SpinBox
label={tr.schedulingEasyInterval()}
subLabel="Days to wait after answering Easy on the first learning step."
warn={goodExceedsEasy}
defaultValue={defaults.graduatingIntervalEasy}
bind:value={config.graduatingIntervalEasy} />
<SpinBoxFloat
label={tr.schedulingStartingEase()}
subLabel="The default multiplier when a review is answered Good."
min={1.31}
max={5}
defaultValue={defaults.initialEase}
value={config.initialEase}
on:changed={(evt) => (config.easyMultiplier = evt.detail.value)} />
<CheckBox
label="Bury New"
subLabel={tr.schedulingBuryRelatedNewCardsUntilThe()}
defaultValue={defaults.buryNew}
bind:value={config.buryNew} />
</div>

View File

@ -0,0 +1,30 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<style>
:global(svg) {
vertical-align: text-bottom;
}
</style>
<div class="btn-group">
<button type="button" class="btn btn-primary">Save</button>
<button
type="button"
class="btn btn-primary dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">Toggle Dropdown</span>
</button>
<ul class="dropdown-menu">
<li><a class="dropdown-item" href={'#'}>Add</a></li>
<li><a class="dropdown-item" href={'#'}>Rename</a></li>
<li><a class="dropdown-item" href={'#'}>Remove</a></li>
<li>
<hr class="dropdown-divider" />
</li>
<input type="checkbox" class="form-check-input" id="dropdownCheck" />
<label class="form-check-label" for="dropdownCheck"> Apply to Children </label>
</ul>
</div>

View File

@ -0,0 +1,33 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { revertIcon } from "./icons";
import { createEventDispatcher } from "svelte";
export let value: any;
export let defaultValue: any;
const dispatch = createEventDispatcher();
let modified: boolean;
$: modified = JSON.stringify(value) !== JSON.stringify(defaultValue);
/// This component can be used either with bind:value, or by listening
/// to the revert event.
function revert(): void {
value = JSON.parse(JSON.stringify(defaultValue));
dispatch("revert", { value });
}
</script>
<style>
:global(svg) {
vertical-align: text-bottom;
}
</style>
{#if modified}
<span on:click={revert}>{@html revertIcon}</span>
{/if}

View File

@ -0,0 +1,66 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte";
export let config: pb.BackendProto.DeckConfig.Config;
export let defaults: pb.BackendProto.DeckConfig.Config;
</script>
<div>
<h2>Reviews</h2>
<SpinBox
label={tr.schedulingMaximumReviewsday()}
subLabel="The maximum number of reviews cards to show in a day."
min={0}
defaultValue={defaults.reviewsPerDay}
bind:value={config.reviewsPerDay} />
<SpinBoxFloat
label={tr.schedulingEasyBonus()}
subLabel="Extra multiplier applied when answering Easy on a review card."
min={1}
max={3}
defaultValue={defaults.easyMultiplier}
value={config.easyMultiplier}
on:changed={(evt) => (config.easyMultiplier = evt.detail.value)} />
<SpinBoxFloat
label={tr.schedulingIntervalModifier()}
subLabel="Multiplier applied to all reviews."
min={0.5}
max={2}
defaultValue={defaults.intervalMultiplier}
value={config.intervalMultiplier}
on:changed={(evt) => (config.intervalMultiplier = evt.detail.value)} />
<SpinBox
label={tr.schedulingMaximumInterval()}
subLabel="The longest number of days a review card will wait."
min={1}
max={365 * 100}
defaultValue={defaults.maximumReviewInterval}
bind:value={config.maximumReviewInterval} />
<SpinBoxFloat
label={tr.schedulingHardInterval()}
subLabel="Multiplier applied to review interval when Hard is pressed."
min={0.5}
max={1.3}
defaultValue={defaults.hardMultiplier}
value={config.hardMultiplier}
on:changed={(evt) => (config.hardMultiplier = evt.detail.value)} />
<CheckBox
label="Bury Reviews"
subLabel={tr.schedulingBuryRelatedReviewsUntilTheNext()}
defaultValue={defaults.buryReviews}
bind:value={config.buryReviews} />
</div>

View File

@ -0,0 +1,34 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import ConfigEntry from "./ConfigEntry.svelte";
export let label: string;
export let subLabel: string;
export let value: number;
export let min = 1;
export let max = 9999;
export let warn = false;
export let defaultValue: number = 0;
function blur() {
if (value > max) {
value = max;
} else if (value < min) {
value = min;
}
}
</script>
<ConfigEntry {label} {subLabel} bind:value {defaultValue}>
<input
type="number"
{min}
{max}
bind:value
on:blur={blur}
class:warn
class="form-control" />
</ConfigEntry>

View File

@ -0,0 +1,42 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher } from "svelte";
import ConfigEntry from "./ConfigEntry.svelte";
import type { NumberValueEvent } from "./events";
export let label: string;
export let subLabel: string;
export let value: number;
export let defaultValue: number;
export let min = 1;
export let max = 9999;
const dispatch = createEventDispatcher();
let stringValue: string;
$: stringValue = value.toFixed(2);
function update(this: HTMLInputElement): void {
dispatch("changed", {
value: Math.min(max, Math.max(min, parseFloat(this.value))),
});
}
function revert(evt: NumberValueEvent): void {
dispatch("changed", { value: evt.detail.value });
}
</script>
<ConfigEntry {label} {subLabel} {value} {defaultValue} on:revert={revert}>
<input
type="number"
{min}
{max}
step="0.01"
value={stringValue}
on:blur={update}
class="form-control" />
</ConfigEntry>

View File

@ -0,0 +1,45 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { stepsToString, stringToSteps } from "./steps";
import ConfigEntry from "./ConfigEntry.svelte";
import type { NumberValueEvent } from "./events";
export let label: string;
export let subLabel: string;
export let value: number[];
export let defaultValue: number[];
export let warn: boolean = false;
const dispatch = createEventDispatcher();
let stringValue: string;
$: stringValue = stepsToString(value);
function update(this: HTMLInputElement): void {
const value = stringToSteps(this.value);
dispatch("changed", { value });
}
function revert(evt: NumberValueEvent): void {
dispatch("changed", { value: evt.detail.value });
}
</script>
<style lang="scss">
input {
width: 100%;
}
</style>
<ConfigEntry {label} {subLabel} {value} {defaultValue} on:revert={revert}>
<input
type="text"
value={stringValue}
on:blur={update}
class:warn
class="form-control" />
</ConfigEntry>

View File

@ -0,0 +1,15 @@
@use "ts/sass/scrollbar";
@use "ts/sass/core";
@import "ts/bootstrap/functions";
@import "ts/bootstrap/variables";
@import "ts/bootstrap/mixins";
@import "ts/bootstrap/helpers";
@import "ts/bootstrap/dropdown";
@import "ts/bootstrap/forms";
@import "ts/bootstrap/buttons";
@import "ts/bootstrap/button-group";
.night-mode {
@include scrollbar.night-mode;
}

View File

@ -0,0 +1,19 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name="viewport" id="viewport" content="width=device-width" />
<link href="deckconfig-base.css" rel="stylesheet" />
<link href="deckconfig.css" rel="stylesheet" />
<script src="../js/vendor/protobuf.min.js"></script>
<script src="../js/vendor/bootstrap.bundle.min.js"></script>
<script src="deckconfig.js"></script>
</head>
<body>
<div id="main"></div>
<script>
anki.deckConfig(document.getElementById("main"), 1);
</script>
</body>
</html>

4
ts/deckconfig/events.ts Normal file
View File

@ -0,0 +1,4 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export type NumberValueEvent = { detail: { value: number } };

7
ts/deckconfig/icons.ts Normal file
View File

@ -0,0 +1,7 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// Import icons from bootstrap
import revertIcon from "./arrow-counterclockwise.svg";
export { revertIcon };

23
ts/deckconfig/index.ts Normal file
View File

@ -0,0 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getDeckConfigInfo, stateFromUpdateData } from "./lib";
import { setupI18n, ModuleName } from "anki/i18n";
import { checkNightMode } from "anki/nightmode";
import DeckConfigPage from "./DeckConfigPage.svelte";
export async function deckConfig(
target: HTMLDivElement,
deckId: number
): Promise<void> {
checkNightMode();
await setupI18n({
modules: [ModuleName.SCHEDULING, ModuleName.ACTIONS, ModuleName.DECK_CONFIG],
});
const info = await getDeckConfigInfo(deckId);
const state = stateFromUpdateData(info);
new DeckConfigPage({
target,
props: { state },
});
}

52
ts/deckconfig/lib.ts Normal file
View File

@ -0,0 +1,52 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import pb from "anki/backend_proto";
import { postRequest } from "anki/postrequest";
export async function getDeckConfigInfo(
deckId: number
): Promise<pb.BackendProto.DeckConfigForUpdate> {
return pb.BackendProto.DeckConfigForUpdate.decode(
await postRequest("/_anki/deckConfigForUpdate", JSON.stringify({ deckId }))
);
}
export type DeckConfigId = number;
export interface ConfigWithCount {
config: pb.BackendProto.DeckConfig;
useCount: number;
}
export interface DeckConfigState {
deckName: string;
selectedConfigId: DeckConfigId;
removedConfigs: DeckConfigId[];
renamedConfigs: Map<DeckConfigId, string>;
allConfigs: ConfigWithCount[];
defaults: pb.BackendProto.DeckConfig.Config;
}
export function stateFromUpdateData(
data: pb.BackendProto.DeckConfigForUpdate
): DeckConfigState {
const current = data.currentDeck as pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
return {
deckName: current.name,
selectedConfigId: current.configId,
removedConfigs: [],
renamedConfigs: new Map(),
allConfigs: data.allConfig.map((config) => {
return {
config: config.config as pb.BackendProto.DeckConfig,
useCount: config.useCount!,
};
}),
defaults: data.defaults!.config! as pb.BackendProto.DeckConfig.Config,
};
}

View File

@ -0,0 +1,23 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { stepsToString, stringToSteps } from "./steps";
test("whole steps", () => {
const steps = [1, 10, 60, 120, 1440];
const string = "1m 10m 1h 2h 1d";
expect(stepsToString(steps)).toBe(string);
expect(stringToSteps(string)).toStrictEqual(steps);
});
test("fractional steps", () => {
const steps = [1 / 60, 5 / 60, 1.5, 400];
const string = "1s 5s 90s 400m";
expect(stepsToString(steps)).toBe(string);
expect(stringToSteps(string)).toStrictEqual(steps);
});
test("parsing", () => {
expect(stringToSteps("")).toStrictEqual([]);
expect(stringToSteps(" ")).toStrictEqual([]);
expect(stringToSteps("1 hello 2")).toStrictEqual([1, 2]);
});

70
ts/deckconfig/steps.ts Normal file
View File

@ -0,0 +1,70 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { TimespanUnit, naturalWholeUnit, unitAmount, unitSeconds } from "anki/time";
function unitSuffix(unit: TimespanUnit): string {
switch (unit) {
case TimespanUnit.Seconds:
return "s";
case TimespanUnit.Minutes:
return "m";
case TimespanUnit.Hours:
return "h";
case TimespanUnit.Days:
return "d";
default:
// should not happen
return "";
}
}
function suffixToUnit(suffix: string): TimespanUnit {
switch (suffix) {
case "s":
return TimespanUnit.Seconds;
case "h":
return TimespanUnit.Hours;
case "d":
return TimespanUnit.Days;
default:
return TimespanUnit.Minutes;
}
}
function minutesToString(step: number): string {
const secs = step * 60;
let unit = naturalWholeUnit(secs);
if ([TimespanUnit.Months, TimespanUnit.Years].includes(unit)) {
unit = TimespanUnit.Days;
}
const amount = unitAmount(unit, secs);
return `${amount}${unitSuffix(unit)}`;
}
function stringToMinutes(text: string): number {
const match = text.match(/(\d+)(.*)/);
if (match) {
const [_, num, suffix] = match;
const unit = suffixToUnit(suffix);
const seconds = unitSeconds(unit) * parseInt(num, 10);
return seconds / 60;
} else {
return 0;
}
}
export function stepsToString(steps: number[]): string {
return steps.map(minutesToString).join(" ");
}
export function stringToSteps(text: string): number[] {
return (
text
.split(" ")
.map(stringToMinutes)
// remove zeros
.filter((e) => e)
);
}

View File

@ -6,14 +6,17 @@ load("//ts:vendor.bzl", "copy_bootstrap_icons")
load("//ts:compile_sass.bzl", "compile_sass")
compile_sass(
srcs = [
"editable.scss",
"editor.scss",
],
group = "base_css",
srcs = ["editor.scss", "editable.scss"],
visibility = ["//visibility:public"],
deps = [
"//ts/sass:base_lib",
"//ts/sass:buttons_lib",
"//ts/sass:scrollbar_lib",
],
visibility = ["//visibility:public"],
)
ts_library(
@ -41,9 +44,9 @@ esbuild(
entry_point = "index_wrapper.ts",
visibility = ["//visibility:public"],
deps = [
"base_css",
":bootstrap-icons",
":editor_ts",
"base_css",
],
)