More Flags - Add flags with custom names and colors

Yes

Kind regards,

Ali

I got similar reports about macOS. I don’t have a Mac to test on so the issue will take a while to get fixed.

1 Like

This Mac freeze problem seems to be caused by an incomplete modal “open()”.

The add-ons manager is modal, and config of more flags is modal but it does not seem to be in front of the screen and is blocked by the add-ons manager, but the add-ons manager is also blocked and cannot be operated, so users cannot operate Anki. (I don’t know why)

So using “exec” instead should solve the problem. (or “exec_” in Qt5, or open config from tools instead of addons manager.)

# __init__.py, line 372

def on_config() -> None:
    dialog = ConfigDialog(mw)
    # dialog.open()
    if hasattr(dialog, "exec"):
        dialog.exec()
    else:
        dialog.exec_()

Edit 2024-12-07: I received a reply from a Mac user who read this thread and tried it and said that it opens the window but does not save the data, but on my device it seems to work fine so I am not sure about this. So maybe something still needs to be adjusted.

2 Likes

Started getting a crash after the Mac fix 0.0.7:

Anki 24.11 (87ccd24e)  (ao)
Python 3.9.18 Qt 6.6.2 PyQt 6.6.1
Platform: Windows-10-10.0.26100

When loading More Flags - Add card flags with custom names and colors:
Traceback (most recent call last):
  File "aqt.addons", line 250, in loadAddons
  File "C:\Users\jormk\AppData\Roaming\Anki2\addons21\146757953\__init__.py", line 39, in <module>
    from .gui.config import ConfigDialog
  File "C:\Users\jormk\AppData\Roaming\Anki2\addons21\146757953\gui\config.py", line 3, in <module>
    import webcolors
ModuleNotFoundError: No module named 'webcolors'

From what I can see, this import was already there since 0.0.3 so It’s weird that it’s crashing for me now.

1 Like

Should be fixed now. Thank you for the report.

1 Like

Hi @abdo ,

Recently found that “More Flags” addon attaches its Sentry handler to loggers of other addons.
Sometimes it leads to errors:

— Logging error —
Traceback (most recent call last):
File “logging”, line 1086, in emit
UnicodeEncodeError: ‘ascii’ codec can’t encode character ‘\u2068’ in position 192: ordinal not in range(128)
Call stack:
File “”, line 1, in
File “aqt”, line 571, in run
File “aqt”, line 785, in _run
File “aqt.taskman”, line 144, in _on_closures_pending
File “aqt.taskman”, line 88, in
File “aqt.operations”, line 261, in wrapped_done
File “aqt.deckbrowser”, line 171, in success
File “aqt.deckbrowser”, line 187, in __renderPage
File “_aqt.hooks”, line 1837, in call
File “/Users/SuhasK/Library/Application Support/Anki2/addons21/1188705668/ui/deck_browser/deck_browser_hooks.py”, line 43, in __on_browser_will_render_content
log.debug(f"DeckBrowserContent stats (edited): {content.stats}”)
File “logging”, line 1434, in debug
File “logging”, line 1589, in _log
File “logging”, line 1599, in handle
File “/Users/SuhasK/Library/Application Support/Anki2/addons21/146757953/vendor/sentry_sdk/integrations/logging.py”, line 96, in sentry_patched_callhandlers
return old_callhandlers(self, record)
File “logging”, line 1661, in callHandlers
File “logging”, line 952, in handle
File “logging”, line 1187, in emit
File “logging”, line 1091, in emit
Message: ‘DeckBrowserContent stats (edited):
Studied \u2068106\u2069 cards\n\u2068in \u20685.55\u2069 minutes\u2069 today\n(\u20683.14\u2069s/card)

Full log

What do you think is the best way to handle this issue?

CC @SuhasK

1 Like

Here’s some JS code for anyone interested in enabling the use of your More Flags on AnkiDroid (maybe it works on AnkiMobile too?)

The general idea of setting up an awaited Promise in the custom scheduler that will edit customData with the values it receives on being resolved can be used to add any customData values dynamically. I already set up the custom scheduler part of the code to receive an arbitrary dictionary that it will then write into customData. The bigger part of the equation is adding the functionality (and likely UI) on the card template side to add what data values you want. In this case the only things added is one "cf" = 1/2/3... value.

Code

_customFlagSetter.js
var isNightMode = document.body.classList.contains("nightMode");

var mainContainer = document.getElementById(
  "custom_flag_container"
);
if (!mainContainer) {
  mainContainer = document.createElement("div");
  mainContainer.id = "custom_flag_container";
  mainContainer.className = "inactive";
  const trigger = document.createElement("button");
  trigger.id = "custom_flag_trigger";
  trigger.innerText = "";
  mainContainer.appendChild(trigger);

  // Add directly to body, this causes the container to persist between cards on desktop
  document.body.appendChild(mainContainer);

  mainContainer.onclick = (event) => {
    event.stopPropagation();
  };

  trigger.onclick = function (event) {
    switch (mainContainer.className) {
      case "inactive":
        mainContainer.className = "active";
        break;
      case "active":
        mainContainer.className = "inactive";
        break;
    }
    document.onclick = () => { mainContainer.className = "inactive"; };
  }
}

if (!globalThis.Persistence) {
  (async () => {
    await import("./_ankiPersistence.js");
  })();
}

// Assuming card template is including an element with is-back attribute on the backside
var isFront = !!document.querySelector("#is-front");

globalThis.selectedFlag = globalThis.selectedFlag || null;
if (globalThis.Persistence.isAvailable()) {
  const storedFlag = globalThis.Persistence.getItem("selectedFlag");
  if (storedFlag) {
    globalThis.selectedFlag = storedFlag;
    if (!isFront) {
      globalThis.Persistence.removeItem("selectedFlag");
    }
  }
};


// Setup the current card flag setter
var flagSetter = document.getElementById("custom_flag_setter");
// Remove the previous flag setter if it exists
if (flagSetter) {
  flagSetter.remove();
}
flagSetter = document.createElement("div");
flagSetter.id = "custom_flag_setter";
mainContainer.appendChild(flagSetter);
// Reset trigger on changing cards or keep same between front and back
var useStoredState = !isFront && globalThis.selectedFlag;
var trigger = document.getElementById("custom_flag_trigger");
trigger.style.color = useStoredState ? globalThis.selectedFlag.color : "inherit";
trigger.innerHTML = useStoredState ? "<span style='color: green;'>✔ </span>" : "";



function makeFlagRadioButton({
  flagNum,
  label,
  color_light = '#fff',
  color_dark = '#000',
} = {}) {
  const flagRadioDiv = document.createElement("div");
  const flagRadio = document.createElement("input");
  flagRadio.type = "radio";
  flagRadio.id = `flag${flagNum}`;
  flagRadio.name = `flag${flagNum}`;
  flagRadio.value = flagNum;

  const color = isNightMode ? color_light : color_dark;
  const flagSymbol = flagNum > 0 ? '⚑' : '⚐';
  const flagLabel = document.createElement("label");
  flagLabel.htmlFor = `flag${flagNum}`;
  flagLabel.innerHTML = `<span style="color: ${color};">${flagSymbol} ${label}</span>`;
  flagRadio.addEventListener("change", (e) => {
    const flag = e.target.value;
    const isChecked = e.target.checked;
    if (isChecked) {
      // Set zero to null, null is used to indicate removal of a key from customData
      globalThis.selectedFlag = flag === "0" ? null : { flag, color };
      // Uncheck all other radios
      document.querySelectorAll("input[type=radio]").forEach((radio) => {
        if (radio !== e.target) {
          radio.checked = false;
        }
      });
      // Enable save button
      document.getElementById("custom_flag_save_button").disabled = false;
    }
  });
  flagRadioDiv.appendChild(flagRadio);
  flagRadioDiv.appendChild(flagLabel);

  return flagRadioDiv
};


/**
 * Add custom flag setter to the container.
 * 
 * @param {any} [flagContainer=flagSetter] Optional custom flag container to be provided.
 *   The container also have the id "custom_flag_setter" so that css can be applied to it.
 */
function addCustomFlagSetter(flagContainer = flagSetter) {
  // Load More flags config json generated by Addon Config Sync
  const configJsonPromise = fetch("./_146757953_meta.json").then(response => response.json());
  // flags are an array in config.flags in the json, call makeFlagButton for each
  configJsonPromise.then((module) => {
    const flagsDef = module?.config?.flags;
    if (!Array.isArray(flagsDef) || flagsDef.length === 0) {
      return;
    }
    // Clear the flag container from any previous flags
    flagContainer.innerHTML = "";
    // Add a "No Flag" option first
    flagsDef.unshift({ label: "No Flag", flagNum: 0 });
    flagsDef.forEach((flagDef, i) => {
      // Note, we turn flag 0 into null
      const flagNum = i;
      const { label, color_light, color_dark, flag } = flagDef;
      const flagRadioDiv = makeFlagRadioButton({ flagNum, label, color_light, color_dark, flag });
      flagContainer.appendChild(flagRadioDiv);
    });

    // Add save button, this will resolve the promise with the selected flag and can only be set
    // once oer card front
    const saveButtonRow = document.createElement("div");
    const msgSpan = document.createElement("span");
    msgSpan.id = "custom_flag_save_message";
    msgSpan.innerText = "Click Save to finalize selection to customData.";
    saveButtonRow.appendChild(msgSpan);


    saveButtonRow.id = "custom_flag_save_button_row";
    const saveButton = document.createElement("button");
    // Should be disabled until a flag is selected
    saveButton.disabled = true;
    saveButton.id = "custom_flag_save_button";
    saveButton.innerText = "Save";
    saveButton.onclick = function () {
      if (globalThis.selectedFlag) {
        globalThis.Persistence.setItem("selectedFlag", globalThis.selectedFlag);
      }
      const { flag, color } = globalThis.selectedFlag || {};
      globalThis.resolveGetCustomFlagsPromise({ "cf": flag });
      // Disable all radios, since changing the value needs a reload
      document.querySelectorAll("input[type=radio]").forEach((radio) => {
        radio.disabled = true;
      });
      const trigger = document.getElementById("custom_flag_trigger");
      trigger.style.color = color;
      trigger.innerHTML = "<span style='color: green;'>✔ </span>";
      // Add message on how to reset selection
      msgSpan.innerText = "Selection saved, to reset you need to reload the card.";
    };
    saveButtonRow.appendChild(saveButton);
    flagContainer.appendChild(saveButtonRow);

    const UA = navigator.userAgent;
    const isAndroidWebview = /wv/i.test(UA);

    // Add a stickied button a the bottom of the screen that will also resolve the promise
    // On AnkiDroid, the awaiting promise blocks advancing to the card back so this hack
    // is needed to allow advancing without setting a flag
    if (isAndroidWebview) {
      // Change message if no selection made
      if (!globalThis.selectedFlag) {
        msgSpan.innerText = "Saving on card back not possible on AnkiDroid";
        document.querySelectorAll("input[type=radio]").forEach((radio) => {
          radio.disabled = true;
        });
        saveButton.disabled = true;
      }

      const fixedResolveButton = document.createElement("div");
      fixedResolveButton.id = "android_fixed_resolve_button";
      // Trigger on touchstart to make triggering with one touch on the edge of the
      // Show answer button easier. It's still rather precise to try to trigger
      // both with one touch
      fixedResolveButton.ontouchstart = function () {
        globalThis.resolveGetCustomFlagsPromise();
        // Hide the button after resolving as it is not needed anymore
        fixedResolveButton.style = "display: none;";
      };
      document.body.appendChild(fixedResolveButton);
      document.body.style.height = "100vw";
    }
  });

}

addCustomFlagSetter();

In order to get your flag settings, I’ve taken advantage of my own Addon Config Sync which creates a copy of every addon’s config file in the collection.media folder. This enables importing that JSON file in Javascript and thus reading the flag settings you’ve made on AnkiDroid!

If you don’t want to use the addon you’ll need to replace this part of the code with a manual copy of your flag settings. Which you’d need to also manually update whenever they change, so using the addon is very convenient.

  // Load More flags config json generated by Addon Config Sync
  const configJsonPromise = fetch("./_146757953_meta.json").then(response => response.json());
  // flags are an array in config.flags in the json, call makeFlagButton for each
  configJsonPromise.then((module) => {
    const flagsDef = module?.config?.flags;
    if (!Array.isArray(flagsDef) || flagsDef.length === 0) {
      return;
    }
_customFlagSetter.css
#custom_flag_container {
  position: absolute;
  font-family: Arial, Helvetica, sans-serif;
  top: 0;
  right: 0;
  font-size: 0.9em;
  opacity: 0.7;
  z-index: 1000;
  pointer-events: all;
}
#custom_flag_trigger {
  font-size: 0.9em;
  padding: 0.2em 0.6em;
  margin: 0;
  border-radius: 0.3em;
  border: 1px solid transparent;
  cursor: pointer;
  z-index: 1000;
  float: right;
  pointer-events: all;
}
#custom_flag_trigger:hover {
  border-color: gray;
}
#custom_flag_setter {
  position: relative;
  top: 1.8em;
  right: 0;
  display: none;
  padding: 0.5em;
  text-align: left;
  line-height: 1em;
  border-radius: 0.3em;
  border: 1px solid darkgray;
  background-color: #fff;
}
.nightMode #custom_flag_setter {
  background-color: #333;
}
#custom_flag_save_button_row {
  width: 180px;
  display: flex;
  flex-flow: row;
  align-items: flex-end;
}
#custom_flag_save_button {
  margin: 0;
  margin-top: 0.5em;
  padding: 0.2em 0.6em;
  border-radius: 0.3em;
  border: 1px solid transparent;
}
#android_fixed_resolve_button {
  position: fixed;
  bottom: -10px;
  margin: 0;
  height: 70px;
  width: 100%;
  background-color: black;
  opacity: 0.01;
  z-index: 1001;
}
#custom_flag_save_message {
  font-size: 0.8em;
  display: inline;
  word-break: keep-all;
}
#custom_flag_container.active #custom_flag_setter {
  display: block;
}
#custom_flag_container.inactive #custom_flag_setter {
  display: none;
}
#custom_flag_container.active #custom_flag_trigger:after {
  content: "⚑ ▲";
}
#custom_flag_container.inactive #custom_flag_trigger:after {
  content: "⚑ ▼";
}
#custom_flag_container.active {
  opacity: 1;
}
#custom_flag_container.inactive {
  opacity: 0.7;
}
Custom scheduler code
// Add card front identifier div
var isFront = document.querySelector("#is-front")
if (!isFront) {
  isFront = document.createElement("div");
  isFront.id = "is-front";
  document.querySelector("#qa").appendChild(isFront);
}

/**
 * Promise to set custom data when resolved. Can be resolved multiple times.
 */
globalThis.getCustomFlagsPromise = new Promise((resolve) => {
  globalThis.resolveGetCustomFlagsPromise = resolve;
});

globalThis.getCustomFlagsPromise.then((newCustomData) => {
  if (typeof newCustomData !== 'object' || newCustomData === null) {
    return;
  }
  Object.entries(newCustomData).forEach(([key, value]) => {
    ['again', 'hard', 'good', 'easy'].forEach((type) => {
      if (value === null) {
        delete customData[type][key];
      } else {
        customData[type][key] = value;
      }
    });
  });
});

// Assign to AnkiDroid user action button a function that will resolve the promise
// and show the answer as a slightly faster alternative to the hack of the fixed button
// on the bottom of the screen
globalThis.userJs1 = function () {
  globalThis.resolveGetCustomFlagsPromise();
  showAnswer();
};

// Await the promise to get the custom data, the scheduler hook will
// only write the custom data once when it is allowed to resolve
await globalThis.getCustomFlagsPromise;

console.log("customData", customData);

How to use

  1. Copy Custom scheduler code to the Custom scheduling in the deck Advanced section
  2. Copy _customFlagSetter.js and _customFlagSetter.css to the collection.media folder
  3. Import JS in card front and card back with <script src="_customFlagSetter.js"></script> . The JS can run as the very last thing.
  4. Import CSS into Card styling with @import url("_customFlagSetter.css");
  5. Install Addon Config Sync and sync once to create a copy of your More Flags config JSON.

Basic usage

This creates a small button at the top right that you can click to open to select flags. The selected flag can be saved only once because once the scheduler hook has been allowed to finish, further edits to custom data will not get saved on answering the card. To undo selection you need to reload the card (exit and re-enter reviewer)

¨

I made the UI use the popover style because I personally would need to flag things only occasionally.

Desktop

Surprisingly, you can set the custom data on both the card front and back. Might be just an FSRS thing as, if I understand correctly, it ignores the interval values given by the custom scheduler? The extra convenience doesn’t really matter much since you can use the addon on desktop to flag in the card browser.

The JS flagging might just a tiny bit faster? You still need a few clicks to tag. You could change the display to instead show flag buttons always inline that’d require one click to flag (but still need a card reload to undo).

AnkiDroid

The main point, no addon on mobile, so the reviewer is the only way you can add custom data to a card through the custom scheduler. There’s more unfortunate drawbacks here:

  • Flagging only works from the card front, if you advance to the back and decide you want to flag, you’ll need re-enter the reviewer.
  • Annoyingly, advancing to the card back is blocked until the awaited Promise in the custom scheduler is resolved. I suppose this should’ve been the expected result and that it works nicely on desktop is weird.
    • I’ve bound a function to userJs1 so you could bind some button on your phone to that to show the card back and resolve the promise in one action. I’ve bound mine to Volume Up.
3 Likes

Hmm, does passing encoding="utf-8" to your FileHandler help?

2 Likes

Oops, I missed that the reviewer does also get partially blocked on the card front on desktop too. I somehow did not look down when testing on desktop until today :smiley:

This happens:

  • Card front is displayed like usual
  • On pressing Show Answer without doing flagging the card back is shown but…
  • The answer buttons do not show. Instead the Show Answer button remains displayed.
  • Pressing Show Answer again causes the reviewer to just re-render the card back (as you’d expect, it shows the answer)
  • It’s still possible to answer the card normally with the keyboard buttons 1,2,3,4 or space.

This is yet another fun way that the desktop reviewer JS handling differs from AnkiDroid.

1 Like

When searching for cards which do not have cf=1 (i.e. a search like -prop:cdn:cf=1) [any particula number, not necessarily 1], cards which do not have any flag are not shown. Is this intended behaviour?

When flagging cards, I am not able to Undo the flagging action with the shortcut Cmd + Z. Is it possible to add that?
Anki 24.11 (87ccd24e) (ao)
Python 3.9.18 Qt 6.6.2 PyQt 6.6.1
Platform: macOS-13.7.5-x86_64-i386-64bit

This is due to the way prop:cdn is implemented in Anki. You can use the has-cd:cf query to find cards w/o any custom flag.

It probably broke after some update. Will look into the issue as soon as possible.

2 Likes