Check URLs in TS code (#2436)

* Move help page URLs in ts to new file

* Unnest linkchecker test module

* Check TS help pages

* Add a comment (dae)
This commit is contained in:
RumovZ 2023-03-15 06:46:03 +01:00 committed by GitHub
parent b55161cd39
commit f9126927b1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 196 additions and 105 deletions

2
Cargo.lock generated
View file

@ -2011,7 +2011,9 @@ dependencies = [
"anki", "anki",
"futures", "futures",
"itertools", "itertools",
"lazy_static",
"linkcheck", "linkcheck",
"regex",
"reqwest", "reqwest",
"strum", "strum",
"tokio", "tokio",

View file

@ -19,6 +19,8 @@ linkcheck = { git = "https://github.com/ankitects/linkcheck.git", rev = "2f20798
futures = "0.3.25" futures = "0.3.25"
itertools = "0.10.5" itertools = "0.10.5"
lazy_static = "1.4.0"
regex = "1.7.1"
strum = { version = "0.24.1", features = ["derive"] } strum = { version = "0.24.1", features = ["derive"] }
tokio = { version = "1.24.2", features = ["full"] } tokio = { version = "1.24.2", features = ["full"] }
workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" } workspace-hack = { version = "0.1", path = "../../tools/workspace-hack" }

View file

@ -1,94 +1,137 @@
// 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
#![cfg(test)]
use std::borrow::Cow;
use std::env;
use std::iter;
use anki::links::HelpPage; use anki::links::HelpPage;
use futures::StreamExt;
use itertools::Itertools;
use lazy_static::lazy_static;
use linkcheck::validation::check_web;
use linkcheck::validation::Context;
use linkcheck::validation::Reason;
use linkcheck::BasicContext;
use regex::Regex;
use reqwest::Url;
use strum::IntoEnumIterator;
#[cfg(test)] /// Aggregates [`Outcome`]s by collecting the error messages of the invalid
mod test { /// ones.
use std::env; #[derive(Default)]
use std::iter; struct Outcomes(Vec<String>);
use futures::StreamExt; enum Outcome {
use itertools::Itertools; Valid,
use linkcheck::validation::check_web; Invalid(String),
use linkcheck::validation::Context; }
use linkcheck::validation::Reason;
use linkcheck::BasicContext;
use reqwest::Url;
use strum::IntoEnumIterator;
use super::*; #[derive(Clone)]
enum CheckableUrl {
HelpPage(HelpPage),
String(&'static str),
}
/// Aggregates [`Outcome`]s by collecting the error messages of the invalid impl CheckableUrl {
/// ones. fn url(&self) -> Cow<str> {
#[derive(Default)] match *self {
struct Outcomes(Vec<String>); Self::HelpPage(page) => page.to_link().into(),
Self::String(s) => s.into(),
enum Outcome {
Valid,
Invalid(String),
}
#[tokio::test]
async fn check_links() {
if env::var("ONLINE_TESTS").is_err() {
println!("test disabled; ONLINE_TESTS not set");
return;
}
let ctx = BasicContext::default();
let result = futures::stream::iter(HelpPage::iter())
.map(|page| check_page(page, &ctx))
.buffer_unordered(ctx.concurrency())
.collect::<Outcomes>()
.await;
if !result.0.is_empty() {
panic!("{}", result.message());
} }
} }
async fn check_page(page: HelpPage, ctx: &BasicContext) -> Outcome { fn anchor(&self) -> Cow<str> {
let link = page.to_link(); match *self {
match Url::parse(&link) { Self::HelpPage(page) => page.to_link_suffix().into(),
Ok(url) => { Self::String(s) => s.split('#').last().unwrap_or_default().into(),
if url.as_str() == link {
match check_web(&url, ctx).await {
Ok(()) => Outcome::Valid,
Err(Reason::Dom) => Outcome::Invalid(format!(
"'#{}' not found on '{}{}'",
url.fragment().unwrap(),
url.domain().unwrap(),
url.path(),
)),
Err(Reason::Web(err)) => Outcome::Invalid(err.to_string()),
_ => unreachable!(),
}
} else {
Outcome::Invalid(format!(
"'{}' is not a valid URL part",
page.to_link_suffix(),
))
}
}
Err(err) => Outcome::Invalid(err.to_string()),
}
}
impl Extend<Outcome> for Outcomes {
fn extend<T: IntoIterator<Item = Outcome>>(&mut self, items: T) {
for outcome in items {
match outcome {
Outcome::Valid => (),
Outcome::Invalid(err) => self.0.push(err),
}
}
}
}
impl Outcomes {
fn message(&self) -> String {
iter::once("invalid links found:")
.chain(self.0.iter().map(String::as_str))
.join("\n - ")
} }
} }
} }
impl From<HelpPage> for CheckableUrl {
fn from(value: HelpPage) -> Self {
Self::HelpPage(value)
}
}
impl From<&'static str> for CheckableUrl {
fn from(value: &'static str) -> Self {
Self::String(value)
}
}
fn ts_help_pages() -> impl Iterator<Item = &'static str> {
lazy_static! {
static ref QUOTED_URL: Regex = Regex::new("\"(http.+)\"").unwrap();
}
QUOTED_URL
.captures_iter(include_str!("../../../ts/lib/help-page.ts"))
.map(|caps| caps.get(1).unwrap().as_str())
}
#[tokio::test]
async fn check_links() {
if env::var("ONLINE_TESTS").is_err() {
println!("test disabled; ONLINE_TESTS not set");
return;
}
let ctx = BasicContext::default();
let result = futures::stream::iter(
HelpPage::iter()
.map(CheckableUrl::from)
.chain(ts_help_pages().map(CheckableUrl::from)),
)
.map(|page| check_url(page, &ctx))
.buffer_unordered(ctx.concurrency())
.collect::<Outcomes>()
.await;
if !result.0.is_empty() {
panic!("{}", result.message());
}
}
async fn check_url(page: CheckableUrl, ctx: &BasicContext) -> Outcome {
let link = page.url();
match Url::parse(&link) {
Ok(url) => {
if url.as_str() == link {
match check_web(&url, ctx).await {
Ok(()) => Outcome::Valid,
Err(Reason::Dom) => Outcome::Invalid(format!(
"'#{}' not found on '{}{}'",
url.fragment().unwrap(),
url.domain().unwrap(),
url.path(),
)),
Err(Reason::Web(err)) => Outcome::Invalid(err.to_string()),
_ => unreachable!(),
}
} else {
Outcome::Invalid(format!("'{}' is not a valid URL part", page.anchor(),))
}
}
Err(err) => Outcome::Invalid(err.to_string()),
}
}
impl Extend<Outcome> for Outcomes {
fn extend<T: IntoIterator<Item = Outcome>>(&mut self, items: T) {
for outcome in items {
match outcome {
Outcome::Valid => (),
Outcome::Invalid(err) => self.0.push(err),
}
}
}
}
impl Outcomes {
fn message(&self) -> String {
iter::once("invalid links found:")
.chain(self.0.iter().map(String::as_str))
.join("\n - ")
}
}

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -29,32 +30,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
maximumInterval: { maximumInterval: {
title: tr.schedulingMaximumInterval(), title: tr.schedulingMaximumInterval(),
help: tr.deckConfigMaximumIntervalTooltip(), help: tr.deckConfigMaximumIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#maximum-interval", url: HelpPage.DeckOptions.maximumInterval,
}, },
startingEase: { startingEase: {
title: tr.schedulingStartingEase(), title: tr.schedulingStartingEase(),
help: tr.deckConfigStartingEaseTooltip(), help: tr.deckConfigStartingEaseTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#starting-ease", url: HelpPage.DeckOptions.startingEase,
}, },
easyBonus: { easyBonus: {
title: tr.schedulingEasyBonus(), title: tr.schedulingEasyBonus(),
help: tr.deckConfigEasyBonusTooltip(), help: tr.deckConfigEasyBonusTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#easy-bonus", url: HelpPage.DeckOptions.easyBonus,
}, },
intervalModifier: { intervalModifier: {
title: tr.schedulingIntervalModifier(), title: tr.schedulingIntervalModifier(),
help: tr.deckConfigIntervalModifierTooltip(), help: tr.deckConfigIntervalModifierTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#interval-modifier", url: HelpPage.DeckOptions.intervalModifier,
}, },
hardInterval: { hardInterval: {
title: tr.schedulingHardInterval(), title: tr.schedulingHardInterval(),
help: tr.deckConfigHardIntervalTooltip(), help: tr.deckConfigHardIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#hard-interval", url: HelpPage.DeckOptions.hardInterval,
}, },
newInterval: { newInterval: {
title: tr.schedulingNewInterval(), title: tr.schedulingNewInterval(),
help: tr.deckConfigNewIntervalTooltip(), help: tr.deckConfigNewIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#new-interval", url: HelpPage.DeckOptions.newInterval,
}, },
customScheduling: { customScheduling: {
title: tr.deckConfigCustomScheduling(), title: tr.deckConfigCustomScheduling(),
@ -76,7 +77,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigAdvancedTitle()}> <TitledContainer title={tr.deckConfigAdvancedTitle()}>
<HelpModal <HelpModal
title={tr.deckConfigAdvancedTitle()} title={tr.deckConfigAdvancedTitle()}
url="https://docs.ankiweb.net/deck-options.html#advanced" url={HelpPage.DeckOptions.advanced}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -46,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigAudioTitle()}> <TitledContainer title={tr.deckConfigAudioTitle()}>
<HelpModal <HelpModal
title={tr.deckConfigAudioTitle()} title={tr.deckConfigAudioTitle()}
url="https://docs.ankiweb.net/deck-options.html#audio" url={HelpPage.DeckOptions.audio}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -54,7 +55,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigBuryTitle()}> <TitledContainer title={tr.deckConfigBuryTitle()}>
<HelpModal <HelpModal
title={tr.deckConfigBuryTitle()} title={tr.deckConfigBuryTitle()}
url="https://docs.ankiweb.net/studying.html#siblings-and-burying" url={HelpPage.Studying.siblingsAndBurying}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -135,17 +136,17 @@
newLimit: { newLimit: {
title: tr.schedulingNewCardsday(), title: tr.schedulingNewCardsday(),
help: tr.deckConfigNewLimitTooltip() + v3Extra, help: tr.deckConfigNewLimitTooltip() + v3Extra,
url: "https://docs.ankiweb.net/deck-options.html#new-cardsday", url: HelpPage.DeckOptions.newCardsday,
}, },
reviewLimit: { reviewLimit: {
title: tr.schedulingMaximumReviewsday(), title: tr.schedulingMaximumReviewsday(),
help: tr.deckConfigReviewLimitTooltip() + reviewV3Extra, help: tr.deckConfigReviewLimitTooltip() + reviewV3Extra,
url: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday", url: HelpPage.DeckOptions.maximumReviewsday,
}, },
newCardsIgnoreReviewLimit: { newCardsIgnoreReviewLimit: {
title: tr.deckConfigNewCardsIgnoreReviewLimit(), title: tr.deckConfigNewCardsIgnoreReviewLimit(),
help: newCardsIgnoreReviewLimitHelp, help: newCardsIgnoreReviewLimitHelp,
url: "https://docs.ankiweb.net/deck-options.html#new-cardsday", url: HelpPage.DeckOptions.newCardsday,
}, },
}; };
const helpSections = Object.values(settings) as DeckOption[]; const helpSections = Object.values(settings) as DeckOption[];
@ -162,7 +163,7 @@
<TitledContainer title={tr.deckConfigDailyLimits()}> <TitledContainer title={tr.deckConfigDailyLimits()}>
<HelpModal <HelpModal
title={tr.deckConfigDailyLimits()} title={tr.deckConfigDailyLimits()}
url="https://docs.ankiweb.net/deck-options.html#daily-limits" url={HelpPage.DeckOptions.dailyLimits}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import { DeckConfig } from "@tslib/proto"; import { DeckConfig } from "@tslib/proto";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -127,7 +128,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigOrderingTitle()}> <TitledContainer title={tr.deckConfigOrderingTitle()}>
<HelpModal <HelpModal
title={tr.deckConfigOrderingTitle()} title={tr.deckConfigOrderingTitle()}
url="https://docs.ankiweb.net/deck-options.html#display-order" url={HelpPage.DeckOptions.displayOrder}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -42,22 +43,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
relearningSteps: { relearningSteps: {
title: tr.deckConfigRelearningSteps(), title: tr.deckConfigRelearningSteps(),
help: tr.deckConfigRelearningStepsTooltip(), help: tr.deckConfigRelearningStepsTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#relearning-steps", url: HelpPage.DeckOptions.relearningSteps,
}, },
minimumInterval: { minimumInterval: {
title: tr.schedulingMinimumInterval(), title: tr.schedulingMinimumInterval(),
help: tr.deckConfigMinimumIntervalTooltip(), help: tr.deckConfigMinimumIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#minimum-interval", url: HelpPage.DeckOptions.minimumInterval,
}, },
leechThreshold: { leechThreshold: {
title: tr.schedulingLeechThreshold(), title: tr.schedulingLeechThreshold(),
help: tr.deckConfigLeechThresholdTooltip(), help: tr.deckConfigLeechThresholdTooltip(),
url: "https://docs.ankiweb.net/leeches.html#leeches", url: HelpPage.Leeches.leeches,
}, },
leechAction: { leechAction: {
title: tr.schedulingLeechAction(), title: tr.schedulingLeechAction(),
help: tr.deckConfigLeechActionTooltip(), help: tr.deckConfigLeechActionTooltip(),
url: "https://docs.ankiweb.net/leeches.html#waiting", url: HelpPage.Leeches.waiting,
}, },
}; };
const helpSections = Object.values(settings) as DeckOption[]; const helpSections = Object.values(settings) as DeckOption[];
@ -74,7 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.schedulingLapses()}> <TitledContainer title={tr.schedulingLapses()}>
<HelpModal <HelpModal
title={tr.schedulingLapses()} title={tr.schedulingLapses()}
url="https://docs.ankiweb.net/deck-options.html#lapses" url={HelpPage.DeckOptions.lapses}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import { DeckConfig } from "@tslib/proto"; import { DeckConfig } from "@tslib/proto";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -58,22 +59,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
learningSteps: { learningSteps: {
title: tr.deckConfigLearningSteps(), title: tr.deckConfigLearningSteps(),
help: tr.deckConfigLearningStepsTooltip(), help: tr.deckConfigLearningStepsTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#learning-steps", url: HelpPage.DeckOptions.learningSteps,
}, },
graduatingInterval: { graduatingInterval: {
title: tr.schedulingGraduatingInterval(), title: tr.schedulingGraduatingInterval(),
help: tr.deckConfigGraduatingIntervalTooltip(), help: tr.deckConfigGraduatingIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#graduating-interval", url: HelpPage.DeckOptions.graduatingInterval,
}, },
easyInterval: { easyInterval: {
title: tr.schedulingEasyInterval(), title: tr.schedulingEasyInterval(),
help: tr.deckConfigEasyIntervalTooltip(), help: tr.deckConfigEasyIntervalTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#easy-interval", url: HelpPage.DeckOptions.easyInterval,
}, },
insertionOrder: { insertionOrder: {
title: tr.deckConfigNewInsertionOrder(), title: tr.deckConfigNewInsertionOrder(),
help: tr.deckConfigNewInsertionOrderTooltip(), help: tr.deckConfigNewInsertionOrderTooltip(),
url: "https://docs.ankiweb.net/deck-options.html#insertion-order", url: HelpPage.DeckOptions.insertionOrder,
}, },
}; };
const helpSections = Object.values(settings) as DeckOption[]; const helpSections = Object.values(settings) as DeckOption[];
@ -90,7 +91,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.schedulingNewCards()}> <TitledContainer title={tr.schedulingNewCards()}>
<HelpModal <HelpModal
title={tr.schedulingNewCards()} title={tr.schedulingNewCards()}
url="https://docs.ankiweb.net/deck-options.html#new-cards" url={HelpPage.DeckOptions.newCards}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import * as tr from "@tslib/ftl"; import * as tr from "@tslib/ftl";
import { HelpPage } from "@tslib/help-page";
import type Carousel from "bootstrap/js/dist/carousel"; import type Carousel from "bootstrap/js/dist/carousel";
import type Modal from "bootstrap/js/dist/modal"; import type Modal from "bootstrap/js/dist/modal";
@ -53,7 +54,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<TitledContainer title={tr.deckConfigTimerTitle()}> <TitledContainer title={tr.deckConfigTimerTitle()}>
<HelpModal <HelpModal
title={tr.deckConfigTimerTitle()} title={tr.deckConfigTimerTitle()}
url="https://docs.ankiweb.net/deck-options.html#timer" url={HelpPage.DeckOptions.timer}
slot="tooltip" slot="tooltip"
{helpSections} {helpSections}
on:mount={(e) => { on:mount={(e) => {

36
ts/lib/help-page.ts Normal file
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
/// These links are checked in CI to ensure they are valid.
export const HelpPage = {
DeckOptions: {
maximumInterval: "https://docs.ankiweb.net/deck-options.html#maximum-interval",
startingEase: "https://docs.ankiweb.net/deck-options.html#starting-ease",
easyBonus: "https://docs.ankiweb.net/deck-options.html#easy-bonus",
intervalModifier: "https://docs.ankiweb.net/deck-options.html#interval-modifier",
hardInterval: "https://docs.ankiweb.net/deck-options.html#hard-interval",
newInterval: "https://docs.ankiweb.net/deck-options.html#new-interval",
advanced: "https://docs.ankiweb.net/deck-options.html#advanced",
timer: "https://docs.ankiweb.net/deck-options.html#timer",
learningSteps: "https://docs.ankiweb.net/deck-options.html#learning-steps",
graduatingInterval: "https://docs.ankiweb.net/deck-options.html#graduating-interval",
easyInterval: "https://docs.ankiweb.net/deck-options.html#easy-interval",
insertionOrder: "https://docs.ankiweb.net/deck-options.html#insertion-order",
newCards: "https://docs.ankiweb.net/deck-options.html#new-cards",
relearningSteps: "https://docs.ankiweb.net/deck-options.html#relearning-steps",
minimumInterval: "https://docs.ankiweb.net/deck-options.html#minimum-interval",
lapses: "https://docs.ankiweb.net/deck-options.html#lapses",
displayOrder: "https://docs.ankiweb.net/deck-options.html#display-order",
maximumReviewsday: "https://docs.ankiweb.net/deck-options.html#maximum-reviewsday",
newCardsday: "https://docs.ankiweb.net/deck-options.html#new-cardsday",
dailyLimits: "https://docs.ankiweb.net/deck-options.html#daily-limits",
audio: "https://docs.ankiweb.net/deck-options.html#audio",
},
Leeches: {
leeches: "https://docs.ankiweb.net/leeches.html#leeches",
waiting: "https://docs.ankiweb.net/leeches.html#waiting",
},
Studying: {
siblingsAndBurying: "https://docs.ankiweb.net/studying.html#siblings-and-burying",
},
};