more experimental updates to deck config screen

- try out bootstrap modals - they're not perfect, but let's see how
they go for now. Won't be hard to switch to bridge commands if required.
- handle adding/renaming/removing
- add a class to manage the state
This commit is contained in:
Damien Elmes 2021-04-16 23:29:21 +10:00
parent a6ed8e90ce
commit c3fc07ac20
15 changed files with 442 additions and 109 deletions

View file

@ -5,10 +5,11 @@ load("//ts/svelte:svelte.bzl", "compile_svelte", "svelte", "svelte_check")
load("//ts:esbuild.bzl", "esbuild") load("//ts:esbuild.bzl", "esbuild")
load("//ts:vendor.bzl", "copy_bootstrap_icons") load("//ts:vendor.bzl", "copy_bootstrap_icons")
load("//ts:compile_sass.bzl", "compile_sass") load("//ts:compile_sass.bzl", "compile_sass")
load("@npm//jest-cli:index.bzl", "jest_test")
compile_sass( compile_sass(
group = "base_css",
srcs = ["deckconfig-base.scss"], srcs = ["deckconfig-base.scss"],
group = "base_css",
visibility = ["//visibility:public"], visibility = ["//visibility:public"],
deps = [ deps = [
"//ts/sass:base_lib", "//ts/sass:base_lib",
@ -24,6 +25,9 @@ svelte_names = [f.replace(".svelte", "") for f in svelte_files]
compile_svelte( compile_svelte(
name = "svelte", name = "svelte",
srcs = svelte_files, srcs = svelte_files,
deps = [
"@npm//@types/bootstrap",
],
) )
copy_bootstrap_icons( copy_bootstrap_icons(
@ -50,12 +54,16 @@ ts_library(
"icons.ts", "icons.ts",
"lib.ts", "lib.ts",
"steps.ts", "steps.ts",
"textInputModal.ts",
], ],
module_name = "deckconfig", module_name = "deckconfig",
deps = [ deps = [
"TextInputModal",
"//ts:image_module_support", "//ts:image_module_support",
"//ts/lib", "//ts/lib",
"//ts/lib:backend_proto", "//ts/lib:backend_proto",
"@npm//lodash-es",
"@npm//svelte",
], ],
) )
@ -112,5 +120,40 @@ svelte_check(
srcs = glob([ srcs = glob([
"*.ts", "*.ts",
"*.svelte", "*.svelte",
]), ]) + [
"@npm//@types/bootstrap",
],
)
ts_library(
name = "test_lib",
srcs = glob(["*.test.ts"]),
tsconfig = "//ts:tsconfig.json",
deps = [
":lib",
"//ts/lib:backend_proto",
"@npm//@types/jest",
],
)
jest_test(
name = "test",
args = [
"--no-cache",
"--no-watchman",
"--ci",
"--colors",
"--config",
"$(location //ts:jest.config.js)",
],
data = [
":test_lib",
"//ts:jest.config.js",
"@npm//protobufjs",
],
target_compatible_with = select({
"@platforms//os:osx": [],
"@platforms//os:linux": [],
"//conditions:default": ["@platforms//os:linux"],
}),
) )

View file

@ -3,14 +3,13 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type pb from "anki/backend_proto";
import NewOptions from "./NewOptions.svelte"; import NewOptions from "./NewOptions.svelte";
import ReviewOptions from "./ReviewOptions.svelte"; import ReviewOptions from "./ReviewOptions.svelte";
import LapseOptions from "./LapseOptions.svelte"; import LapseOptions from "./LapseOptions.svelte";
import GeneralOptions from "./GeneralOptions.svelte"; import GeneralOptions from "./GeneralOptions.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config; export let state: DeckConfigState;
export let defaults: pb.BackendProto.DeckConfig.Config;
</script> </script>
<style lang="scss"> <style lang="scss">
@ -24,8 +23,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</style> </style>
<div> <div>
<NewOptions bind:config {defaults} /> <NewOptions {state} />
<ReviewOptions bind:config {defaults} /> <ReviewOptions {state} />
<LapseOptions bind:config {defaults} /> <LapseOptions {state} />
<GeneralOptions bind:config {defaults} /> <GeneralOptions {state} />
</div> </div>

View file

@ -4,16 +4,19 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "anki/i18n"; import * as tr from "anki/i18n";
import type { DeckConfigId, ConfigWithCount } from "./lib"; import type { DeckConfigState, ConfigListEntry } from "./lib";
import OptionsDropdown from "./OptionsDropdown.svelte"; import OptionsDropdown from "./OptionsDropdown.svelte";
export let allConfig: ConfigWithCount[]; export let state: DeckConfigState;
export let selectedConfigId: DeckConfigId; let configList = state.configList;
function configLabel(config: ConfigWithCount): string { function configLabel(entry: ConfigListEntry): string {
const name = config.config.name; const count = tr.deckConfigUsedByDecks({ decks: entry.useCount });
const count = tr.deckConfigUsedByDecks({ decks: config.useCount }); return `${entry.name} (${count})`;
return `${name} (${count})`; }
function myblur(this: HTMLSelectElement) {
state.setCurrentIndex(parseInt(this.value));
} }
</script> </script>
@ -47,12 +50,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="outer"> <div class="outer">
<div class="inner"> <div class="inner">
<select bind:value={selectedConfigId} class="form-select"> <!-- svelte-ignore a11y-no-onchange -->
{#each allConfig as config} <select class="form-select" on:change={myblur}>
<option value={config.config.id}>{configLabel(config)}</option> {#each $configList as entry}
<option value={entry.idx} selected={entry.current}>
{configLabel(entry)}
</option>
{/each} {/each}
</select> </select>
<OptionsDropdown /> <OptionsDropdown {state} />
</div> </div>
</div> </div>

View file

@ -3,24 +3,12 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type pb from "anki/backend_proto";
import ConfigSelector from "./ConfigSelector.svelte"; import ConfigSelector from "./ConfigSelector.svelte";
import ConfigEditor from "./ConfigEditor.svelte"; import ConfigEditor from "./ConfigEditor.svelte";
import * as tr from "anki/i18n"; import * as tr from "anki/i18n";
import type { DeckConfigState } from "./lib"; import type { DeckConfigState } from "./lib";
export let state: DeckConfigState; 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> </script>
<style> <style>
@ -38,12 +26,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</style> </style>
<div id="modal">
<!-- filled in later-->
</div>
<div class="outer"> <div class="outer">
<div class="inner"> <div class="inner">
<div><b>{tr.actionsOptionsFor({ val: state.deckName })}</b></div> <div><b>{tr.actionsOptionsFor({ val: state.currentDeck.name })}</b></div>
<ConfigSelector allConfig={state.allConfigs} bind:selectedConfigId /> <ConfigSelector {state} />
<ConfigEditor config={selectedConfig} {defaults} /> <ConfigEditor {state} />
</div> </div>
</div> </div>

View file

@ -3,13 +3,14 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n"; import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte"; import SpinBox from "./SpinBox.svelte";
import CheckBox from "./CheckBox.svelte"; import CheckBox from "./CheckBox.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config; export let state: DeckConfigState;
export let defaults: pb.BackendProto.DeckConfig.Config; let config = state.currentConfig;
let defaults = state.defaults;
</script> </script>
<div> <div>
@ -21,23 +22,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={30} min={30}
max={600} max={600}
defaultValue={defaults.capAnswerTimeToSecs} defaultValue={defaults.capAnswerTimeToSecs}
bind:value={config.capAnswerTimeToSecs} /> bind:value={$config.capAnswerTimeToSecs} />
<CheckBox <CheckBox
label="Answer timer" label="Answer timer"
subLabel={tr.schedulingShowAnswerTimer()} subLabel={tr.schedulingShowAnswerTimer()}
defaultValue={defaults.showTimer} defaultValue={defaults.showTimer}
bind:value={config.showTimer} /> bind:value={$config.showTimer} />
<CheckBox <CheckBox
label="Autoplay" label="Autoplay"
subLabel="Don't play audio automatically" subLabel="Don't play audio automatically"
defaultValue={defaults.disableAutoplay} defaultValue={defaults.disableAutoplay}
bind:value={config.disableAutoplay} /> bind:value={$config.disableAutoplay} />
<CheckBox <CheckBox
label="Question Audio" label="Question Audio"
subLabel={tr.schedulingAlwaysIncludeQuestionSideWhenReplaying()} subLabel={tr.schedulingAlwaysIncludeQuestionSideWhenReplaying()}
defaultValue={defaults.skipQuestionWhenReplayingAnswer} defaultValue={defaults.skipQuestionWhenReplayingAnswer}
bind:value={config.skipQuestionWhenReplayingAnswer} /> bind:value={$config.skipQuestionWhenReplayingAnswer} />
</div> </div>

View file

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

View file

@ -3,16 +3,17 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n"; import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte"; import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte"; import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte"; import CheckBox from "./CheckBox.svelte";
import StepsInput from "./StepsInput.svelte"; import StepsInput from "./StepsInput.svelte";
import EnumSelector from "./EnumSelector.svelte"; import EnumSelector from "./EnumSelector.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config; export let state: DeckConfigState;
export let defaults: pb.BackendProto.DeckConfig.Config; let config = state.currentConfig;
let defaults = state.defaults;
const newOrderChoices = [ const newOrderChoices = [
tr.schedulingShowNewCardsInOrderAdded(), tr.schedulingShowNewCardsInOrderAdded(),
@ -21,15 +22,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let stepsExceedGraduatingInterval: boolean; let stepsExceedGraduatingInterval: boolean;
$: { $: {
const lastLearnStepInDays = config.learnSteps.length const lastLearnStepInDays = $config.learnSteps.length
? config.learnSteps[config.learnSteps.length - 1] / 60 / 24 ? $config.learnSteps[$config.learnSteps.length - 1] / 60 / 24
: 0; : 0;
stepsExceedGraduatingInterval = stepsExceedGraduatingInterval =
lastLearnStepInDays > config.graduatingIntervalGood; lastLearnStepInDays > $config.graduatingIntervalGood;
} }
let goodExceedsEasy: boolean; $: goodExceedsEasy =
$: goodExceedsEasy = config.graduatingIntervalGood > config.graduatingIntervalEasy; $config.graduatingIntervalGood > $config.graduatingIntervalEasy;
// fixme: change impl; support warning messages
$: newCardsGreaterThanParent = $config.newPerDay > state.currentDeck.parentNewLimit;
</script> </script>
<div> <div>
@ -40,36 +44,37 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
subLabel="Learning steps, separated by spaces." subLabel="Learning steps, separated by spaces."
warn={stepsExceedGraduatingInterval} warn={stepsExceedGraduatingInterval}
defaultValue={defaults.learnSteps} defaultValue={defaults.learnSteps}
value={config.learnSteps} value={$config.learnSteps}
on:changed={(evt) => (config.learnSteps = evt.detail.value)} /> on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
<EnumSelector <EnumSelector
label={tr.schedulingOrder()} label={tr.schedulingOrder()}
subLabel="" subLabel=""
choices={newOrderChoices} choices={newOrderChoices}
defaultValue={defaults.newCardOrder} defaultValue={defaults.newCardOrder}
bind:value={config.newCardOrder} /> bind:value={$config.newCardOrder} />
<SpinBox <SpinBox
label={tr.schedulingNewCardsday()} label={tr.schedulingNewCardsday()}
subLabel="The maximum number of new cards to introduce in a day." subLabel="The maximum number of new cards to introduce in a day."
min={0} min={0}
warn={newCardsGreaterThanParent}
defaultValue={defaults.newPerDay} defaultValue={defaults.newPerDay}
bind:value={config.newPerDay} /> bind:value={$config.newPerDay} />
<SpinBox <SpinBox
label={tr.schedulingGraduatingInterval()} label={tr.schedulingGraduatingInterval()}
subLabel="Days to wait after answering Good on the last learning step." subLabel="Days to wait after answering Good on the last learning step."
warn={stepsExceedGraduatingInterval || goodExceedsEasy} warn={stepsExceedGraduatingInterval || goodExceedsEasy}
defaultValue={defaults.graduatingIntervalGood} defaultValue={defaults.graduatingIntervalGood}
bind:value={config.graduatingIntervalGood} /> bind:value={$config.graduatingIntervalGood} />
<SpinBox <SpinBox
label={tr.schedulingEasyInterval()} label={tr.schedulingEasyInterval()}
subLabel="Days to wait after answering Easy on the first learning step." subLabel="Days to wait after answering Easy on the first learning step."
warn={goodExceedsEasy} warn={goodExceedsEasy}
defaultValue={defaults.graduatingIntervalEasy} defaultValue={defaults.graduatingIntervalEasy}
bind:value={config.graduatingIntervalEasy} /> bind:value={$config.graduatingIntervalEasy} />
<SpinBoxFloat <SpinBoxFloat
label={tr.schedulingStartingEase()} label={tr.schedulingStartingEase()}
@ -77,12 +82,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1.31} min={1.31}
max={5} max={5}
defaultValue={defaults.initialEase} defaultValue={defaults.initialEase}
value={config.initialEase} value={$config.initialEase}
on:changed={(evt) => (config.initialEase = evt.detail.value)} /> on:changed={(evt) => ($config.initialEase = evt.detail.value)} />
<CheckBox <CheckBox
label="Bury New" label="Bury New"
subLabel={tr.schedulingBuryRelatedNewCardsUntilThe()} subLabel={tr.schedulingBuryRelatedNewCardsUntilThe()}
defaultValue={defaults.buryNew} defaultValue={defaults.buryNew}
bind:value={config.buryNew} /> bind:value={$config.buryNew} />
</div> </div>

View file

@ -2,6 +2,50 @@
Copyright: Ankitects Pty Ltd and contributors Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts">
// import * as tr from "anki/i18n";
import { textInputModal } from "./textInputModal";
import type { DeckConfigState } from "./lib";
export let state: DeckConfigState;
function addConfig(): void {
textInputModal({
title: "Add Config",
prompt: "Name:",
onOk: (text: string) => {
const trimmed = text.trim();
if (trimmed.length) {
state.addConfig(trimmed);
}
},
});
}
function renameConfig(): void {
textInputModal({
title: "Rename Config",
prompt: "Name:",
startingValue: state.getCurrentName(),
onOk: (text: string) => {
state.setCurrentName(text);
},
});
}
function removeConfig(): void {
setTimeout(() => {
if (confirm("Are you sure?")) {
try {
state.removeCurrentConfig();
} catch (err) {
alert(err);
}
}
}, 100);
}
</script>
<style> <style>
:global(svg) { :global(svg) {
vertical-align: text-bottom; vertical-align: text-bottom;
@ -18,13 +62,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span class="visually-hidden">Toggle Dropdown</span> <span class="visually-hidden">Toggle Dropdown</span>
</button> </button>
<ul class="dropdown-menu"> <ul class="dropdown-menu">
<li><a class="dropdown-item" href={'#'}>Add</a></li> <li><a class="dropdown-item" href={'#'} on:click={addConfig}>Add Config</a></li>
<li><a class="dropdown-item" href={'#'}>Rename</a></li> <li>
<li><a class="dropdown-item" href={'#'}>Remove</a></li> <a class="dropdown-item" href={'#'} on:click={renameConfig}>Rename Config</a>
</li>
<li>
<a class="dropdown-item" href={'#'} on:click={removeConfig}>Remove Config</a>
</li>
<li> <li>
<hr class="dropdown-divider" /> <hr class="dropdown-divider" />
</li> </li>
<input type="checkbox" class="form-check-input" id="dropdownCheck" /> <li><a class="dropdown-item" href={'#'}>Apply to Child Decks</a></li>
<label class="form-check-label" for="dropdownCheck"> Apply to Children </label>
</ul> </ul>
</div> </div>

View file

@ -3,14 +3,15 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type pb from "anki/backend_proto";
import * as tr from "anki/i18n"; import * as tr from "anki/i18n";
import SpinBox from "./SpinBox.svelte"; import SpinBox from "./SpinBox.svelte";
import SpinBoxFloat from "./SpinBoxFloat.svelte"; import SpinBoxFloat from "./SpinBoxFloat.svelte";
import CheckBox from "./CheckBox.svelte"; import CheckBox from "./CheckBox.svelte";
import type { DeckConfigState } from "./lib";
export let config: pb.BackendProto.DeckConfig.Config; export let state: DeckConfigState;
export let defaults: pb.BackendProto.DeckConfig.Config; let config = state.currentConfig;
let defaults = state.defaults;
</script> </script>
<div> <div>
@ -21,7 +22,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
subLabel="The maximum number of reviews cards to show in a day." subLabel="The maximum number of reviews cards to show in a day."
min={0} min={0}
defaultValue={defaults.reviewsPerDay} defaultValue={defaults.reviewsPerDay}
bind:value={config.reviewsPerDay} /> bind:value={$config.reviewsPerDay} />
<SpinBoxFloat <SpinBoxFloat
label={tr.schedulingEasyBonus()} label={tr.schedulingEasyBonus()}
@ -29,8 +30,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1} min={1}
max={3} max={3}
defaultValue={defaults.easyMultiplier} defaultValue={defaults.easyMultiplier}
value={config.easyMultiplier} value={$config.easyMultiplier}
on:changed={(evt) => (config.easyMultiplier = evt.detail.value)} /> on:changed={(evt) => ($config.easyMultiplier = evt.detail.value)} />
<SpinBoxFloat <SpinBoxFloat
label={tr.schedulingIntervalModifier()} label={tr.schedulingIntervalModifier()}
@ -38,8 +39,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.5} min={0.5}
max={2} max={2}
defaultValue={defaults.intervalMultiplier} defaultValue={defaults.intervalMultiplier}
value={config.intervalMultiplier} value={$config.intervalMultiplier}
on:changed={(evt) => (config.intervalMultiplier = evt.detail.value)} /> on:changed={(evt) => ($config.intervalMultiplier = evt.detail.value)} />
<SpinBox <SpinBox
label={tr.schedulingMaximumInterval()} label={tr.schedulingMaximumInterval()}
@ -47,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={1} min={1}
max={365 * 100} max={365 * 100}
defaultValue={defaults.maximumReviewInterval} defaultValue={defaults.maximumReviewInterval}
bind:value={config.maximumReviewInterval} /> bind:value={$config.maximumReviewInterval} />
<SpinBoxFloat <SpinBoxFloat
label={tr.schedulingHardInterval()} label={tr.schedulingHardInterval()}
@ -55,12 +56,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min={0.5} min={0.5}
max={1.3} max={1.3}
defaultValue={defaults.hardMultiplier} defaultValue={defaults.hardMultiplier}
value={config.hardMultiplier} value={$config.hardMultiplier}
on:changed={(evt) => (config.hardMultiplier = evt.detail.value)} /> on:changed={(evt) => ($config.hardMultiplier = evt.detail.value)} />
<CheckBox <CheckBox
label="Bury Reviews" label="Bury Reviews"
subLabel={tr.schedulingBuryRelatedReviewsUntilTheNext()} subLabel={tr.schedulingBuryRelatedReviewsUntilTheNext()}
defaultValue={defaults.buryReviews} defaultValue={defaults.buryReviews}
bind:value={config.buryReviews} /> bind:value={$config.buryReviews} />
</div> </div>

View file

@ -0,0 +1,93 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import { onMount, onDestroy } from "svelte";
import Modal from "bootstrap/js/dist/modal";
export let title: string;
export let prompt: string;
export let startingValue = "";
export let onOk: (text: string) => void;
let inputRef: HTMLInputElement;
let modal: Modal;
function onShown(): void {
inputRef.focus();
}
function onHidden(): void {
const container = document.getElementById("modal")!;
container.removeChild(container.firstElementChild!);
}
function onOkClicked(): void {
onOk(inputRef.value);
modal.hide();
}
function onKeyUp(evt: KeyboardEvent): void {
if (evt.code === "Enter") {
onOkClicked();
}
}
onMount(() => {
const container = document.getElementById("modal")!;
container.addEventListener("shown.bs.modal", onShown);
container.addEventListener("hidden.bs.modal", onHidden);
modal = new Modal(container.firstElementChild!, {});
modal.show();
});
onDestroy(() => {
const container = document.getElementById("modal")!;
container.removeEventListener("shown.bs.modal", onShown);
container.removeEventListener("hidden.bs.modal", onHidden);
});
</script>
<div class="modal fade" tabindex="-1" aria-labelledby="modalLabel" aria-hidden="true">
<div class="modal-dialog" on:keyup={onKeyUp}>
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="modalLabel">{title}</h5>
<button
type="button"
class="btn-close"
data-bs-dismiss="modal"
aria-label="Close" />
</div>
<div class="modal-body">
<form>
<div class="mb-3">
<label
for="prompt-input"
class="col-form-label">{prompt}</label>
<input
id="prompt-input"
bind:this={inputRef}
type="text"
class="form-control"
value={startingValue} />
</div>
</form>
</div>
<div class="modal-footer">
<button
type="button"
class="btn btn-secondary"
data-bs-dismiss="modal">Cancel</button>
<button
type="button"
class="btn btn-primary"
on:click={onOkClicked}>OK</button>
</div>
</div>
</div>
</div>

View file

@ -5,6 +5,8 @@
@import "ts/sass/bootstrap/forms"; @import "ts/sass/bootstrap/forms";
@import "ts/sass/bootstrap/buttons"; @import "ts/sass/bootstrap/buttons";
@import "ts/sass/bootstrap/button-group"; @import "ts/sass/bootstrap/button-group";
@import "ts/sass/bootstrap/modal";
@import "ts/sass/bootstrap/close";
.night-mode { .night-mode {
@include scrollbar.night-mode; @include scrollbar.night-mode;

View file

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

View file

@ -7,6 +7,9 @@
import pb from "anki/backend_proto"; import pb from "anki/backend_proto";
import { postRequest } from "anki/postrequest"; import { postRequest } from "anki/postrequest";
import { Writable, writable, get, Readable, readable } from "svelte/store";
import { isEqual, cloneDeep } from "lodash-es";
import * as tr from "anki/i18n";
export async function getDeckConfigInfo( export async function getDeckConfigInfo(
deckId: number deckId: number
@ -23,30 +26,148 @@ export interface ConfigWithCount {
useCount: number; useCount: number;
} }
export interface DeckConfigState { /// Info for showing the top selector
deckName: string; export interface ConfigListEntry {
selectedConfigId: DeckConfigId; idx: number;
removedConfigs: DeckConfigId[]; name: string;
renamedConfigs: Map<DeckConfigId, string>; useCount: number;
allConfigs: ConfigWithCount[]; current: boolean;
defaults: pb.BackendProto.DeckConfig.Config;
} }
export function stateFromUpdateData( type ConfigInner = pb.BackendProto.DeckConfig.Config;
data: pb.BackendProto.DeckConfigForUpdate export class DeckConfigState {
): DeckConfigState { readonly currentConfig: Writable<ConfigInner>;
const current = data.currentDeck as pb.BackendProto.DeckConfigForUpdate.CurrentDeck; readonly configList: Readable<ConfigListEntry[]>;
return { readonly currentDeck: pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
deckName: current.name, readonly defaults: ConfigInner;
selectedConfigId: current.configId,
removedConfigs: [], private configs: ConfigWithCount[];
renamedConfigs: new Map(), private selectedIdx: number;
allConfigs: data.allConfig.map((config) => { private configListSetter?: (val: ConfigListEntry[]) => void;
private removedConfigs: DeckConfigId[] = [];
constructor(data: pb.BackendProto.DeckConfigForUpdate) {
this.currentDeck = data.currentDeck as pb.BackendProto.DeckConfigForUpdate.CurrentDeck;
this.defaults = data.defaults!.config! as ConfigInner;
this.configs = data.allConfig.map((config) => {
return { return {
config: config.config as pb.BackendProto.DeckConfig, config: config.config as pb.BackendProto.DeckConfig,
useCount: config.useCount!, useCount: config.useCount!,
}; };
}), });
defaults: data.defaults!.config! as pb.BackendProto.DeckConfig.Config, this.selectedIdx =
}; this.configs.findIndex((c) => c.config.id === this.currentDeck.configId) ??
0;
// decrement the use count of the starting item, as we'll apply +1 to currently
// selected one at display time
this.configs[this.selectedIdx].useCount -= 1;
this.currentConfig = writable(this.getCurrentConfig());
this.configList = readable(this.getConfigList(), (set) => {
this.configListSetter = set;
return undefined;
});
}
setCurrentIndex(index: number): void {
this.saveCurrentConfig();
this.selectedIdx = index;
this.updateCurrentConfig();
// use counts have changed
this.updateConfigList();
}
/// Persist any changes made to the current config into the list of configs.
saveCurrentConfig(): void {
const config = get(this.currentConfig);
if (!isEqual(config, this.configs[this.selectedIdx].config.config)) {
console.log("save");
this.configs[this.selectedIdx].config.config = config;
this.configs[this.selectedIdx].config.mtimeSecs = 0;
} else {
console.log("no changes");
}
}
getCurrentName(): string {
return this.configs[this.selectedIdx].config.name;
}
setCurrentName(name: string): void {
if (this.configs[this.selectedIdx].config.name === name) {
return;
}
const uniqueName = this.ensureNewNameUnique(name);
this.configs[this.selectedIdx].config.name = uniqueName;
this.configs[this.selectedIdx].config.mtimeSecs = 0;
this.updateConfigList();
}
/// Adds a new config, making it current.
/// not already a new config.
addConfig(name: string): void {
const uniqueName = this.ensureNewNameUnique(name);
const config = pb.BackendProto.DeckConfig.create({
id: 0,
name: uniqueName,
config: cloneDeep(this.defaults),
});
const configWithCount = { config, useCount: 0 };
this.configs.push(configWithCount);
this.selectedIdx = this.configs.length - 1;
this.updateCurrentConfig();
this.updateConfigList();
}
/// Will throw if the default deck is selected.
removeCurrentConfig(): void {
const currentId = this.configs[this.selectedIdx].config.id;
if (currentId === 1) {
throw "can't remove default config";
}
if (currentId !== 0) {
this.removedConfigs.push(currentId);
}
this.configs.splice(this.selectedIdx, 1);
this.selectedIdx = Math.max(0, this.selectedIdx - 1);
this.updateCurrentConfig();
this.updateConfigList();
}
private ensureNewNameUnique(name: string): string {
if (this.configs.find((e) => e.config.name === name) !== undefined) {
return name + (new Date().getTime() / 1000).toFixed(0);
} else {
return name;
}
}
private updateCurrentConfig(): void {
this.currentConfig.set(this.getCurrentConfig());
}
private updateConfigList(): void {
this.configListSetter?.(this.getConfigList());
}
/// Returns a copy of the currently selected config.
private getCurrentConfig(): ConfigInner {
return cloneDeep(this.configs[this.selectedIdx].config.config as ConfigInner);
}
private getConfigList(): ConfigListEntry[] {
const list: ConfigListEntry[] = this.configs.map((c, idx) => {
const useCount = c.useCount + (idx === this.selectedIdx ? 1 : 0);
return {
name: c.config.name,
current: idx === this.selectedIdx,
idx,
useCount,
};
});
list.sort((a, b) =>
a.name.localeCompare(b.name, tr.i18n.langs, { sensitivity: "base" })
);
return list;
}
} }

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
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
*/
import TextInputModal from "./TextInputModal.svelte";
export interface TextInputModalProps {
title: string;
prompt: string;
startingValue?: string;
onOk: (string) => void;
}
export function textInputModal(props: TextInputModalProps): TextInputModal {
const target = document.getElementById("modal")!;
return new TextInputModal({
target,
props,
});
}

View file

@ -100,7 +100,6 @@ jest_test(
data = [ data = [
":test_lib", ":test_lib",
"//ts:jest.config.js", "//ts:jest.config.js",
"//ts:package.json",
"@npm//protobufjs", "@npm//protobufjs",
], ],
target_compatible_with = select({ target_compatible_with = select({