mirror of
https://github.com/storytold/figcraft.git
synced 2026-10-26 03:16:53 -06:00
- Compare changes: a ready-for-dev frame or section keeps a snapshot of itself from just before
its first change (Document::dev_snapshots, saved only while changed; dropped on resolve, re-mark,
other status or delete). `dev.compare {id?, show?}` diffs it layer by layer: added/removed
layers, and property changes formatted like Inspect (`width 120 → 136`, `fill #FF0000 →
#E03030`, text, parent, order, component, auto layout, typography, effects). The compare view
shows before / now side by side or overlaid with a slider, changed layers outlined, the list of
changes and Resolve change.
- Focus view: `dev.focus {id? | direction}` / `dev.unfocus`: one frame drawn and hit alone
(render Options::solo), a bar with name, status, Compare changes, previous / next ready frame and
exit; Escape leaves.
- Annotation categories: defaults plus the file's own with colours (`annotation.categories`,
`addCategory`, `editCategory`, `removeCategory`), `category` on annotations, coloured callouts,
a category filter and editor in the Inspect panel; stored backwards-compatibly.
- Shared widgets::icon_button / canvas_banner / dropdown_lead; has_status moved to the engine.
- Tests: devcompare_tests, devfocus_tests, annotation_categories_tests, doc handoff_tests, render
solo_tests, UI dev_mode_tests.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
472 lines
22 KiB
Rust
472 lines
22 KiB
Rust
//! Dev Mode annotations: notes designers attach to layers for developers, each with optional
|
|
//! pinned properties (width, fill, font size…) that Dev Mode shows with their current values and
|
|
//! an optional category (Development, Accessibility… or one of the file's own, each with a colour).
|
|
//! They are stored on the layer (`Node::annotations`), saved with the file and undoable; the
|
|
//! categories are stored with the document (`Document::annotation_categories`, left out while
|
|
//! they're the defaults).
|
|
|
|
use std::sync::LazyLock;
|
|
|
|
use figcraft_doc::*;
|
|
use serde_json::{Value, json};
|
|
|
|
use crate::cmd::CommandSpec;
|
|
use crate::devinspect::{Prop, Section, properties};
|
|
use crate::params::*;
|
|
use crate::{bad, spec};
|
|
|
|
/// Most annotations on one layer.
|
|
pub const MAX_PER_LAYER: usize = 64;
|
|
/// Longest annotation text (chars).
|
|
pub const MAX_LABEL: usize = 5_000;
|
|
/// Most annotated layers listed or drawn for one page.
|
|
pub const MAX_LISTED: usize = 2_000;
|
|
/// Most annotation categories in a file.
|
|
pub const MAX_CATEGORIES: usize = 64;
|
|
/// Longest category name (chars).
|
|
pub const MAX_CATEGORY_NAME: usize = 64;
|
|
|
|
/// The file's annotation categories (the defaults until it changes them).
|
|
pub fn categories(doc: &Document) -> &[AnnotationCategory] {
|
|
static DEFAULTS: LazyLock<Vec<AnnotationCategory>> = LazyLock::new(AnnotationCategory::defaults);
|
|
doc.annotation_categories.as_deref().unwrap_or(&DEFAULTS)
|
|
}
|
|
|
|
/// Annotation `a`'s category (`None`: uncategorised, or a category the file no longer has).
|
|
pub fn category_of<'d>(doc: &'d Document, a: &Annotation) -> Option<&'d AnnotationCategory> {
|
|
let id = a.category.as_deref()?;
|
|
categories(doc).iter().find(|c| c.id == id)
|
|
}
|
|
|
|
/// The category named by `key`: its id, else its name (ignoring case).
|
|
pub fn find_category<'a>(list: &'a [AnnotationCategory], key: &str) -> Option<&'a AnnotationCategory> {
|
|
let key = key.trim();
|
|
list.iter().find(|c| c.id == key).or_else(|| list.iter().find(|c| c.name.eq_ignore_ascii_case(key)))
|
|
}
|
|
|
|
fn category_json(c: &AnnotationCategory) -> Value {
|
|
json!({"id": c.id, "name": c.name, "color": c.color.to_css_hex()})
|
|
}
|
|
|
|
/// A property an annotation can pin: its id, its label and where Dev Mode's property list shows it
|
|
/// (a section and a row; `None` = the section's rows other pinnable properties don't claim).
|
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
|
pub struct Pinnable {
|
|
pub id: &'static str,
|
|
pub label: &'static str,
|
|
section: &'static str,
|
|
row: Option<&'static str>,
|
|
}
|
|
|
|
const fn pin(id: &'static str, label: &'static str, section: &'static str, row: Option<&'static str>) -> Pinnable {
|
|
Pinnable { id, label, section, row }
|
|
}
|
|
|
|
/// Every pinnable property, in the order Dev Mode lists them.
|
|
pub const PINNABLE: &[Pinnable] = &[
|
|
pin("width", "Width", "Layout", Some("Width")),
|
|
pin("height", "Height", "Layout", Some("Height")),
|
|
pin("top", "Top", "Layout", Some("Top")),
|
|
pin("left", "Left", "Layout", Some("Left")),
|
|
pin("rotation", "Rotation", "Layout", Some("Rotation")),
|
|
pin("opacity", "Opacity", "Layout", Some("Opacity")),
|
|
pin("cornerRadius", "Corner radius", "Layout", Some("Radius")),
|
|
pin("direction", "Direction", "Auto layout", Some("Direction")),
|
|
pin("gap", "Gap", "Auto layout", Some("Gap")),
|
|
pin("padding", "Padding", "Auto layout", Some("Padding")),
|
|
pin("alignment", "Alignment", "Auto layout", Some("Align")),
|
|
pin("fontFamily", "Font", "Typography", Some("Font")),
|
|
pin("fontStyle", "Font style", "Typography", Some("Style")),
|
|
pin("fontSize", "Font size", "Typography", Some("Size")),
|
|
pin("lineHeight", "Line height", "Typography", Some("Line height")),
|
|
pin("letterSpacing", "Letter spacing", "Typography", Some("Letter spacing")),
|
|
pin("textAlign", "Text align", "Typography", Some("Align")),
|
|
pin("fill", "Fill", "Fills", None),
|
|
pin("stroke", "Stroke", "Strokes", None),
|
|
pin("strokeWeight", "Stroke weight", "Strokes", Some("Weight")),
|
|
pin("effects", "Effects", "Effects", None),
|
|
];
|
|
|
|
/// The pinnable property with id (or label) `key`, ignoring case.
|
|
pub fn pinnable(key: &str) -> Option<&'static Pinnable> {
|
|
let key = key.trim();
|
|
PINNABLE.iter().find(|p| p.id.eq_ignore_ascii_case(key) || p.label.eq_ignore_ascii_case(key))
|
|
}
|
|
|
|
/// Whether `n` has anything to show for `p` (a layer without auto layout has no gap…).
|
|
pub fn applies(sections: &[Section], p: &Pinnable) -> bool {
|
|
pinned_in(sections, p).is_some()
|
|
}
|
|
|
|
/// `p`'s value in a layer's inspected `sections`, labelled with `p`'s label (`None` when the
|
|
/// layer doesn't have it).
|
|
pub fn pinned_in(sections: &[Section], p: &Pinnable) -> Option<Prop> {
|
|
let s = sections.iter().find(|s| s.title == p.section)?;
|
|
let rows: Vec<&Prop> = match p.row {
|
|
Some(label) => s.rows.iter().filter(|r| r.label == label).take(1).collect(),
|
|
// The section's own rows: not those another pinnable property shows (strokes' weight).
|
|
None => s.rows.iter().filter(|r| !PINNABLE.iter().any(|q| q.section == p.section && q.row == Some(r.label.as_str()))).collect(),
|
|
};
|
|
let first = rows.first()?;
|
|
let value = match p.row {
|
|
Some(_) => first.value.clone(),
|
|
None => {
|
|
rows.iter().map(|r| if r.label == "Solid" { r.value.clone() } else { format!("{} {}", r.label, r.value) }).collect::<Vec<_>>().join(", ")
|
|
}
|
|
};
|
|
Some(Prop { label: p.label.to_string(), value, color: first.color.clone(), token: first.token.clone() })
|
|
}
|
|
|
|
/// The pinned properties of annotation `a` on layer `id` with their current values (`None` for
|
|
/// those the layer doesn't have); unknown ids are skipped.
|
|
pub fn pinned(doc: &Document, id: NodeId, a: &Annotation) -> Vec<(&'static Pinnable, Option<Prop>)> {
|
|
if a.properties.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
let sections = properties(doc, id);
|
|
a.properties.iter().filter_map(|k| pinnable(k)).map(|p| (p, pinned_in(§ions, p))).collect()
|
|
}
|
|
|
|
/// The annotated layers on `page`, in layer order (bounded).
|
|
pub fn on_page(doc: &Document, page: NodeId) -> Vec<NodeId> {
|
|
let mut out = Vec::new();
|
|
let mut stack = vec![page];
|
|
let mut visited = 0usize;
|
|
while let Some(id) = stack.pop() {
|
|
visited += 1;
|
|
if out.len() >= MAX_LISTED || visited > 200_000 {
|
|
break;
|
|
}
|
|
let Some(n) = doc.node(id) else { continue };
|
|
if !n.annotations.is_empty() {
|
|
out.push(id);
|
|
}
|
|
stack.extend(n.children.iter().rev().copied());
|
|
}
|
|
out
|
|
}
|
|
|
|
/// An annotation as JSON: its layer, index, text and pinned properties with their values.
|
|
pub fn annotation_json(doc: &Document, id: NodeId, index: usize, a: &Annotation) -> Value {
|
|
let name = doc.node(id).map(|n| n.name.clone()).unwrap_or_default();
|
|
let props: Vec<Value> = pinned(doc, id, a)
|
|
.into_iter()
|
|
.map(|(p, v)| {
|
|
let mut o = json!({"property": p.id, "label": p.label, "value": v.as_ref().map(|v| v.value.clone())});
|
|
if let Some(t) = v.as_ref().and_then(|v| v.token.clone()) {
|
|
o["token"] = json!(t);
|
|
}
|
|
if let Some(c) = v.and_then(|v| v.color) {
|
|
o["color"] = json!(c);
|
|
}
|
|
o
|
|
})
|
|
.collect();
|
|
let mut o = json!({"id": id, "name": name, "index": index, "label": a.label, "properties": props});
|
|
if let Some(c) = category_of(doc, a) {
|
|
o["category"] = category_json(c);
|
|
}
|
|
o
|
|
}
|
|
|
|
/// `properties` from params: known ids, each once, in the given order.
|
|
fn parse_properties(cmd: &str, p: &Value) -> crate::Result<Option<Vec<String>>> {
|
|
let Some(v) = get(p, "properties") else { return Ok(None) };
|
|
let Value::Array(list) = v else { return Err(bad(cmd, "`properties` must be a list of property ids")) };
|
|
let mut out: Vec<String> = Vec::new();
|
|
for item in list.iter().take(PINNABLE.len() * 4) {
|
|
let k = item.as_str().unwrap_or_default();
|
|
let found = pinnable(k).ok_or_else(|| {
|
|
let ids: Vec<&str> = PINNABLE.iter().map(|p| p.id).collect();
|
|
bad(cmd, format!("unknown property {item}; one of: {}", ids.join(", ")))
|
|
})?;
|
|
if !out.iter().any(|o| o == found.id) {
|
|
out.push(found.id.to_string());
|
|
}
|
|
}
|
|
Ok(Some(out))
|
|
}
|
|
|
|
/// `label` from params, trimmed of trailing whitespace and capped.
|
|
fn parse_label(cmd: &str, p: &Value) -> crate::Result<Option<String>> {
|
|
match get(p, "label").or_else(|| get(p, "text")) {
|
|
None => Ok(None),
|
|
Some(Value::String(s)) => Ok(Some(s.trim_end().chars().take(MAX_LABEL).collect())),
|
|
Some(_) => Err(bad(cmd, "`label` must be a string")),
|
|
}
|
|
}
|
|
|
|
/// `category` from params: `Some(None)` clears it (null or ""), `Some(Some(id))` sets it, `None`
|
|
/// leaves it.
|
|
fn parse_category(cmd: &str, doc: &Document, p: &Value) -> crate::Result<Option<Option<String>>> {
|
|
match p.get("category") {
|
|
None => Ok(None),
|
|
Some(Value::Null) => Ok(Some(None)),
|
|
Some(Value::String(s)) if s.trim().is_empty() => Ok(Some(None)),
|
|
Some(Value::String(s)) => {
|
|
let list = categories(doc);
|
|
let c = find_category(list, s).ok_or_else(|| {
|
|
let names: Vec<&str> = list.iter().map(|c| c.name.as_str()).collect();
|
|
bad(cmd, format!("no category {s:?}; one of: {}", names.join(", ")))
|
|
})?;
|
|
Ok(Some(Some(c.id.clone())))
|
|
}
|
|
Some(_) => Err(bad(cmd, "`category` must be a category id or name (null: none)")),
|
|
}
|
|
}
|
|
|
|
fn check(cmd: &str, a: &Annotation) -> crate::Result<()> {
|
|
if a.label.trim().is_empty() && a.properties.is_empty() {
|
|
return Err(bad(cmd, "an annotation needs a note or a pinned property"));
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// The layer an annotation command acts on: `id`, else the first selected layer.
|
|
fn layer(cmd: &str, s: &crate::Session, p: &Value) -> crate::Result<NodeId> {
|
|
let id = targets(s, p).first().copied().ok_or_else(|| bad(cmd, "select a layer or pass `id`"))?;
|
|
let n = s.doc()?.node(id).ok_or_else(|| bad(cmd, "no such layer"))?;
|
|
if n.is_page() {
|
|
return Err(bad(cmd, "pages can't be annotated"));
|
|
}
|
|
Ok(id)
|
|
}
|
|
|
|
/// The annotation `index` on `id` must exist.
|
|
fn existing(cmd: &str, s: &crate::Session, id: NodeId, p: &Value) -> crate::Result<usize> {
|
|
let index = req_index(cmd, p, "index")?;
|
|
let count = s.doc()?.node(id).map_or(0, |n| n.annotations.len());
|
|
if index >= count {
|
|
return Err(bad(cmd, format!("no annotation {index} (the layer has {count})")));
|
|
}
|
|
Ok(index)
|
|
}
|
|
|
|
pub fn specs() -> Vec<CommandSpec> {
|
|
vec![
|
|
spec!(
|
|
"annotation.add",
|
|
"Add annotation",
|
|
"{id? (default the first selected layer), label? (the note), properties?: [width|height|top|left|rotation|opacity|cornerRadius|direction|gap|padding|alignment|fontFamily|fontStyle|fontSize|lineHeight|letterSpacing|textAlign|fill|stroke|strokeWeight|effects], category? (id or name)} → {id, index} (a note, pinned properties or both)",
|
|
true,
|
|
|s, p| {
|
|
let cmd = "annotation.add";
|
|
let id = layer(cmd, s, p)?;
|
|
let category = parse_category(cmd, s.doc()?, p)?.flatten();
|
|
let a = Annotation {
|
|
label: parse_label(cmd, p)?.unwrap_or_default(),
|
|
properties: parse_properties(cmd, p)?.unwrap_or_default(),
|
|
category,
|
|
};
|
|
check(cmd, &a)?;
|
|
let n = s.doc_mut()?.get_mut(id)?;
|
|
if n.annotations.len() >= MAX_PER_LAYER {
|
|
return Err(bad(cmd, format!("a layer has at most {MAX_PER_LAYER} annotations")));
|
|
}
|
|
n.annotations.push(a);
|
|
Ok(json!({"id": id, "index": n.annotations.len() - 1}))
|
|
}
|
|
),
|
|
spec!(
|
|
"annotation.edit",
|
|
"Edit annotation",
|
|
"{id?, index, label?, properties?: [..] (replaces the pinned properties), category? (id or name; null: none)}",
|
|
true,
|
|
|s, p| {
|
|
let cmd = "annotation.edit";
|
|
let id = layer(cmd, s, p)?;
|
|
let index = existing(cmd, s, id, p)?;
|
|
let (label, props, category) = (parse_label(cmd, p)?, parse_properties(cmd, p)?, parse_category(cmd, s.doc()?, p)?);
|
|
if label.is_none() && props.is_none() && category.is_none() {
|
|
return Err(bad(cmd, "pass `label`, `properties` and/or `category`"));
|
|
}
|
|
let n = s.doc_mut()?.get_mut(id)?;
|
|
let a = n.annotations.get_mut(index).ok_or_else(|| bad(cmd, "no such annotation"))?;
|
|
if let Some(l) = label {
|
|
a.label = l;
|
|
}
|
|
if let Some(pr) = props {
|
|
a.properties = pr;
|
|
}
|
|
if let Some(c) = category {
|
|
a.category = c;
|
|
}
|
|
check(cmd, a)?;
|
|
Ok(json!({"id": id, "index": index}))
|
|
}
|
|
),
|
|
spec!("annotation.remove", "Remove annotation", "{id?, index? (omit: every annotation on the layer)}", true, |s, p| {
|
|
let cmd = "annotation.remove";
|
|
let id = layer(cmd, s, p)?;
|
|
let index = match get(p, "index") {
|
|
Some(_) => Some(existing(cmd, s, id, p)?),
|
|
None => None,
|
|
};
|
|
let n = s.doc_mut()?.get_mut(id)?;
|
|
if n.annotations.is_empty() {
|
|
return Err(bad(cmd, "the layer has no annotations"));
|
|
}
|
|
match index {
|
|
Some(i) if i < n.annotations.len() => {
|
|
n.annotations.remove(i);
|
|
}
|
|
Some(_) => return Err(bad(cmd, "no such annotation")),
|
|
None => n.annotations.clear(),
|
|
}
|
|
Ok(json!({"id": id, "remaining": n.annotations.len()}))
|
|
}),
|
|
spec!(
|
|
"annotation.list",
|
|
"List annotations",
|
|
"{ids? | id? (those layers), page? (page id, or \"all\"; default the current page), category? (id or name, or \"none\": only those)} → {annotations: [{id, name, index, label, category?: {id, name, color}, properties: [{property, label, value (null when the layer has none), token?, color?}]}]}",
|
|
false,
|
|
|s, p| {
|
|
let doc = s.doc()?;
|
|
// Only one category's (`Some(None)`: the uncategorised).
|
|
let only: Option<Option<String>> = match string(p, "category") {
|
|
None => None,
|
|
Some(k) if k.eq_ignore_ascii_case("none") => Some(None),
|
|
Some(k) => Some(Some(
|
|
find_category(categories(doc), &k)
|
|
.map(|c| c.id.clone())
|
|
.ok_or_else(|| bad("annotation.list", format!("no category {k:?}")))?,
|
|
)),
|
|
};
|
|
let shown = |a: &Annotation| only.as_ref().is_none_or(|want| category_of(doc, a).map(|c| c.id.as_str()) == want.as_deref());
|
|
let layers: Vec<NodeId> = if get(p, "ids").is_some() || get(p, "id").is_some() {
|
|
targets(s, p).into_iter().filter(|i| doc.contains(*i)).collect()
|
|
} else if string(p, "page").is_some_and(|v| v.eq_ignore_ascii_case("all")) {
|
|
doc.pages.iter().flat_map(|pg| on_page(doc, *pg)).take(MAX_LISTED).collect()
|
|
} else {
|
|
let page = id(p, "page").unwrap_or(s.page()?);
|
|
if !doc.pages.contains(&page) {
|
|
return Err(bad("annotation.list", format!("{page} is not a page")));
|
|
}
|
|
on_page(doc, page)
|
|
};
|
|
let list: Vec<Value> = layers
|
|
.iter()
|
|
.filter_map(|i| doc.node(*i).map(|n| (i, n)))
|
|
.flat_map(|(i, n)| n.annotations.iter().enumerate().filter(|(_, a)| shown(a)).map(move |(k, a)| annotation_json(doc, *i, k, a)))
|
|
.collect();
|
|
Ok(json!({"annotations": list}))
|
|
}
|
|
),
|
|
spec!(
|
|
"annotation.categories",
|
|
"Annotation categories",
|
|
"{} → {categories: [{id, name, color, count (annotations in it, every page)}]}",
|
|
false,
|
|
|s, _| {
|
|
let doc = s.doc()?;
|
|
let mut counts: std::collections::BTreeMap<&str, usize> = Default::default();
|
|
for c in doc.nodes().flat_map(|n| n.annotations.iter()).filter_map(|a| a.category.as_deref()) {
|
|
*counts.entry(c).or_default() += 1;
|
|
}
|
|
let list: Vec<Value> = categories(doc)
|
|
.iter()
|
|
.map(|c| {
|
|
let mut o = category_json(c);
|
|
o["count"] = json!(counts.get(c.id.as_str()).copied().unwrap_or(0));
|
|
o
|
|
})
|
|
.collect();
|
|
Ok(json!({"categories": list}))
|
|
}
|
|
),
|
|
spec!("annotation.addCategory", "Add annotation category", "{name, color? (default the first unused colour)} → {id}", true, |s, p| {
|
|
let cmd = "annotation.addCategory";
|
|
let mut list = categories(s.doc()?).to_vec();
|
|
if list.len() >= MAX_CATEGORIES {
|
|
return Err(bad(cmd, format!("a file has at most {MAX_CATEGORIES} categories")));
|
|
}
|
|
let name = category_name(cmd, &list, None, p)?.ok_or_else(|| bad(cmd, "`name` required"))?;
|
|
let color = match category_color(cmd, p)? {
|
|
Some(c) => c,
|
|
None => {
|
|
let used = |c: &figcraft_color::Color| list.iter().any(|x| x.color.to_hex() == c.to_hex());
|
|
let fallback = AnnotationCategory::COLORS
|
|
.get(list.len() % AnnotationCategory::COLORS.len())
|
|
.copied()
|
|
.unwrap_or(figcraft_color::Color::BLACK);
|
|
AnnotationCategory::COLORS.iter().find(|c| !used(c)).copied().unwrap_or(fallback)
|
|
}
|
|
};
|
|
let id = (1..=MAX_CATEGORIES + 1).map(|k| format!("custom-{k}")).find(|i| !list.iter().any(|c| c.id == *i)).unwrap_or_default();
|
|
list.push(AnnotationCategory { id: id.clone(), name, color });
|
|
s.doc_mut()?.annotation_categories = Some(list);
|
|
Ok(json!({"id": id}))
|
|
}),
|
|
spec!("annotation.editCategory", "Edit annotation category", "{id (id or name), name?, color?} (rename and/or recolour)", true, |s, p| {
|
|
let cmd = "annotation.editCategory";
|
|
let mut list = categories(s.doc()?).to_vec();
|
|
let key = req_string(cmd, p, "id")?;
|
|
let k = find_category(&list, &key)
|
|
.and_then(|c| list.iter().position(|x| x.id == c.id))
|
|
.ok_or_else(|| bad(cmd, format!("no category {key:?}")))?;
|
|
let (name, color) = (category_name(cmd, &list, Some(k), p)?, category_color(cmd, p)?);
|
|
if name.is_none() && color.is_none() {
|
|
return Err(bad(cmd, "pass `name` and/or `color`"));
|
|
}
|
|
let c = list.get_mut(k).ok_or_else(|| bad(cmd, "no such category"))?;
|
|
if let Some(n) = name {
|
|
c.name = n;
|
|
}
|
|
if let Some(col) = color {
|
|
c.color = col;
|
|
}
|
|
let id = c.id.clone();
|
|
s.doc_mut()?.annotation_categories = Some(list);
|
|
Ok(json!({"id": id}))
|
|
}),
|
|
spec!(
|
|
"annotation.removeCategory",
|
|
"Delete annotation category",
|
|
"{id (id or name)} → {id, uncategorized (how many annotations were in it)} (its annotations stay, without a category)",
|
|
true,
|
|
|s, p| {
|
|
let cmd = "annotation.removeCategory";
|
|
let doc = s.doc()?;
|
|
let mut list = categories(doc).to_vec();
|
|
let key = req_string(cmd, p, "id")?;
|
|
let id = find_category(&list, &key).map(|c| c.id.clone()).ok_or_else(|| bad(cmd, format!("no category {key:?}")))?;
|
|
list.retain(|c| c.id != id);
|
|
let in_it = |a: &Annotation| a.category.as_deref() == Some(id.as_str());
|
|
let layers: Vec<NodeId> = doc.nodes().filter(|n| n.annotations.iter().any(in_it)).map(|n| n.id).collect();
|
|
let doc = s.doc_mut()?;
|
|
doc.annotation_categories = Some(list);
|
|
let mut count = 0usize;
|
|
for l in layers {
|
|
for a in doc.get_mut(l)?.annotations.iter_mut().filter(|a| in_it(a)) {
|
|
a.category = None;
|
|
count += 1;
|
|
}
|
|
}
|
|
Ok(json!({"id": id, "uncategorized": count}))
|
|
}
|
|
),
|
|
]
|
|
}
|
|
|
|
/// `name` from params for category `editing` (`None`: a new one): trimmed, capped, not empty and
|
|
/// not another category's.
|
|
fn category_name(cmd: &str, list: &[AnnotationCategory], editing: Option<usize>, p: &Value) -> crate::Result<Option<String>> {
|
|
let Some(v) = get(p, "name") else { return Ok(None) };
|
|
let name: String = v.as_str().ok_or_else(|| bad(cmd, "`name` must be a string"))?.trim().chars().take(MAX_CATEGORY_NAME).collect();
|
|
if name.is_empty() {
|
|
return Err(bad(cmd, "a category needs a name"));
|
|
}
|
|
if list.iter().enumerate().any(|(i, c)| Some(i) != editing && c.name.eq_ignore_ascii_case(&name)) {
|
|
return Err(bad(cmd, format!("there is already a category named {name:?}")));
|
|
}
|
|
Ok(Some(name))
|
|
}
|
|
|
|
/// `color` from params, opaque.
|
|
fn category_color(cmd: &str, p: &Value) -> crate::Result<Option<figcraft_color::Color>> {
|
|
match get(p, "color") {
|
|
Some(v) => Ok(Some(color_of(v).ok_or_else(|| bad(cmd, "`color` must be a colour (hex or CSS)"))?.with_alpha(1.0))),
|
|
None => Ok(None),
|
|
}
|
|
}
|