Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fe491ec427 |
@@ -49,6 +49,7 @@ pub type Result<T> = std::result::Result<T, NominaError>;
|
|||||||
pub struct RenameContext {
|
pub struct RenameContext {
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
pub total: usize,
|
pub total: usize,
|
||||||
|
pub absolute_index: usize,
|
||||||
pub original_name: String,
|
pub original_name: String,
|
||||||
pub extension: String,
|
pub extension: String,
|
||||||
pub path: PathBuf,
|
pub path: PathBuf,
|
||||||
@@ -65,6 +66,7 @@ impl RenameContext {
|
|||||||
Self {
|
Self {
|
||||||
index,
|
index,
|
||||||
total: 1,
|
total: 1,
|
||||||
|
absolute_index: index,
|
||||||
original_name: String::new(),
|
original_name: String::new(),
|
||||||
extension: String::new(),
|
extension: String::new(),
|
||||||
path: PathBuf::new(),
|
path: PathBuf::new(),
|
||||||
|
|||||||
@@ -33,14 +33,33 @@ impl Pipeline {
|
|||||||
self.steps.push(PipelineStep { rule, mode });
|
self.steps.push(PipelineStep { rule, mode });
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn preview(&self, files: &[crate::FileEntry]) -> Vec<PreviewResult> {
|
pub fn preview(&self, files: &[crate::FileEntry], shared_numbering: bool) -> Vec<PreviewResult> {
|
||||||
|
// precompute per-type counts and indices when using separate numbering
|
||||||
|
let dir_count = if shared_numbering { 0 } else { files.iter().filter(|f| f.is_dir).count() };
|
||||||
|
let file_count = if shared_numbering { 0 } else { files.iter().filter(|f| !f.is_dir).count() };
|
||||||
|
let mut dir_idx: usize = 0;
|
||||||
|
let mut file_idx: usize = 0;
|
||||||
|
|
||||||
let results: Vec<PreviewResult> = files
|
let results: Vec<PreviewResult> = files
|
||||||
.iter()
|
.iter()
|
||||||
.enumerate()
|
.enumerate()
|
||||||
.map(|(i, file)| {
|
.map(|(i, file)| {
|
||||||
|
let (index, total) = if shared_numbering {
|
||||||
|
(i, files.len())
|
||||||
|
} else if file.is_dir {
|
||||||
|
let idx = dir_idx;
|
||||||
|
dir_idx += 1;
|
||||||
|
(idx, dir_count)
|
||||||
|
} else {
|
||||||
|
let idx = file_idx;
|
||||||
|
file_idx += 1;
|
||||||
|
(idx, file_count)
|
||||||
|
};
|
||||||
|
|
||||||
let ctx = RenameContext {
|
let ctx = RenameContext {
|
||||||
index: i,
|
index,
|
||||||
total: files.len(),
|
total,
|
||||||
|
absolute_index: i,
|
||||||
original_name: file.name.clone(),
|
original_name: file.name.clone(),
|
||||||
extension: file.extension.clone(),
|
extension: file.extension.clone(),
|
||||||
path: file.path.clone(),
|
path: file.path.clone(),
|
||||||
@@ -183,7 +202,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")];
|
let files = vec![make_file("IMG_001.jpg"), make_file("IMG_002.jpg")];
|
||||||
let results = pipeline.preview(&files);
|
let results = pipeline.preview(&files, true);
|
||||||
assert_eq!(results[0].new_name, "photo-001.jpg");
|
assert_eq!(results[0].new_name, "photo-001.jpg");
|
||||||
assert_eq!(results[1].new_name, "photo-002.jpg");
|
assert_eq!(results[1].new_name, "photo-002.jpg");
|
||||||
}
|
}
|
||||||
@@ -215,7 +234,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let files = vec![make_file("IMG_001.jpg")];
|
let files = vec![make_file("IMG_001.jpg")];
|
||||||
let results = pipeline.preview(&files);
|
let results = pipeline.preview(&files, true);
|
||||||
assert_eq!(results[0].new_name, "PHOTO-001.jpg");
|
assert_eq!(results[0].new_name, "PHOTO-001.jpg");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -238,7 +257,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
|
|
||||||
let files = vec![make_file("same.txt"), make_file("same.txt")];
|
let files = vec![make_file("same.txt"), make_file("same.txt")];
|
||||||
let results = pipeline.preview(&files);
|
let results = pipeline.preview(&files, true);
|
||||||
assert!(results[0].has_conflict);
|
assert!(results[0].has_conflict);
|
||||||
assert!(results[1].has_conflict);
|
assert!(results[1].has_conflict);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ impl TextEditorRule {
|
|||||||
|
|
||||||
impl RenameRule for TextEditorRule {
|
impl RenameRule for TextEditorRule {
|
||||||
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
fn apply(&self, filename: &str, context: &RenameContext) -> String {
|
||||||
if let Some(name) = self.names.get(context.index) {
|
if let Some(name) = self.names.get(context.absolute_index) {
|
||||||
if !name.is_empty() {
|
if !name.is_empty() {
|
||||||
return name.clone();
|
return name.clone();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ pub async fn preview_rename(
|
|||||||
directory: String,
|
directory: String,
|
||||||
filters: Option<FilterConfig>,
|
filters: Option<FilterConfig>,
|
||||||
selected_paths: Option<Vec<String>>,
|
selected_paths: Option<Vec<String>>,
|
||||||
|
shared_numbering: Option<bool>,
|
||||||
) -> Result<Vec<PreviewResult>, String> {
|
) -> Result<Vec<PreviewResult>, String> {
|
||||||
let scanner = FileScanner::new(
|
let scanner = FileScanner::new(
|
||||||
PathBuf::from(&directory),
|
PathBuf::from(&directory),
|
||||||
@@ -104,13 +105,19 @@ pub async fn preview_rename(
|
|||||||
);
|
);
|
||||||
let all_files = scanner.scan();
|
let all_files = scanner.scan();
|
||||||
|
|
||||||
let files = if let Some(ref paths) = selected_paths {
|
let mut files = if let Some(ref paths) = selected_paths {
|
||||||
let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect();
|
let file_map: std::collections::HashMap<PathBuf, _> = all_files.into_iter().map(|f| (f.path.clone(), f)).collect();
|
||||||
paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect()
|
paths.iter().filter_map(|p| file_map.get(&PathBuf::from(p)).cloned()).collect()
|
||||||
} else {
|
} else {
|
||||||
all_files
|
all_files
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// sort: folders before files, then case-insensitive alphabetical
|
||||||
|
files.sort_by(|a, b| {
|
||||||
|
b.is_dir.cmp(&a.is_dir)
|
||||||
|
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
|
||||||
|
});
|
||||||
|
|
||||||
let mut pipeline = Pipeline::new();
|
let mut pipeline = Pipeline::new();
|
||||||
for cfg in &rules {
|
for cfg in &rules {
|
||||||
if let Some(rule) = build_rule(cfg) {
|
if let Some(rule) = build_rule(cfg) {
|
||||||
@@ -127,7 +134,7 @@ pub async fn preview_rename(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(pipeline.preview(&files))
|
Ok(pipeline.preview(&files, shared_numbering.unwrap_or(false)))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
|
|||||||
@@ -410,7 +410,7 @@ function PipelineCard({ rule, index, onScrollToCenter }: { rule: PipelineRule; i
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
requestAnimationFrame(() => {
|
requestAnimationFrame(() => {
|
||||||
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
|
const el = cardRef.current?.closest("[data-slot='popover']")?.querySelector<HTMLElement>(
|
||||||
".p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
|
".p-4 textarea, .p-4 input, .p-4 select, .p-4 button, .p-4 [tabindex='0']"
|
||||||
);
|
);
|
||||||
el?.focus();
|
el?.focus();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useRuleStore } from "@/stores/ruleStore";
|
import { useRuleStore } from "@/stores/ruleStore";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { Input } from "@/components/ui/input";
|
import { Input } from "@/components/ui/input";
|
||||||
import { NumberInput } from "@/components/ui/number-input";
|
import { NumberInput } from "@/components/ui/number-input";
|
||||||
import { SegmentedControl } from "@/components/ui/segmented-control";
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
||||||
@@ -25,6 +26,8 @@ const bases = [
|
|||||||
export function NumberingConfig({ ruleId }: { ruleId: string }) {
|
export function NumberingConfig({ ruleId }: { ruleId: string }) {
|
||||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
|
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as NumberingConfigType | undefined;
|
||||||
const updateRule = useRuleStore((s) => s.updateRule);
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
|
const sharedNumbering = useSettingsStore((s) => s.sharedNumbering);
|
||||||
|
const setSharedNumbering = useSettingsStore((s) => s.setSharedNumbering);
|
||||||
if (!rule) return null;
|
if (!rule) return null;
|
||||||
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
|
const update = (changes: Partial<NumberingConfigType>) => updateRule(ruleId, changes);
|
||||||
|
|
||||||
@@ -74,7 +77,14 @@ export function NumberingConfig({ ruleId }: { ruleId: string }) {
|
|||||||
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
|
<Checkbox checked={rule.reverse} onCheckedChange={(c) => update({ reverse: !!c })} />
|
||||||
Reverse order
|
Reverse order
|
||||||
</label>
|
</label>
|
||||||
|
<label className="flex items-center gap-1.5 cursor-pointer text-xs">
|
||||||
|
<Checkbox checked={sharedNumbering} onCheckedChange={(c) => setSharedNumbering(!!c)} />
|
||||||
|
Shared numbering
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-[11px] text-muted-foreground -mt-1">
|
||||||
|
Shared numbering counts files and folders together. When off, they get separate sequences.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,47 +1,77 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
|
import { invoke } from "@tauri-apps/api/core";
|
||||||
import { useRuleStore } from "@/stores/ruleStore";
|
import { useRuleStore } from "@/stores/ruleStore";
|
||||||
import { useFileStore } from "@/stores/fileStore";
|
import { useFileStore } from "@/stores/fileStore";
|
||||||
|
import { useSettingsStore } from "@/stores/settingsStore";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
|
import type { TextEditorConfig as TextEditorConfigType } from "@/types/rules";
|
||||||
|
import type { PreviewResult } from "@/types/files";
|
||||||
|
|
||||||
export function TextEditorConfig({ ruleId }: { ruleId: string }) {
|
export function TextEditorConfig({ ruleId }: { ruleId: string }) {
|
||||||
const rule = useRuleStore((s) => s.pipeline.find((r) => r.id === ruleId))?.config as TextEditorConfigType | undefined;
|
const pipeline = useRuleStore((s) => s.pipeline);
|
||||||
|
const rule = pipeline.find((r) => r.id === ruleId)?.config as TextEditorConfigType | undefined;
|
||||||
const updateRule = useRuleStore((s) => s.updateRule);
|
const updateRule = useRuleStore((s) => s.updateRule);
|
||||||
const sortedFilePaths = useFileStore((s) => s.sortedFilePaths);
|
const currentPath = useFileStore((s) => s.currentPath);
|
||||||
|
const filters = useFileStore((s) => s.filters);
|
||||||
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
const selectedFiles = useFileStore((s) => s.selectedFiles);
|
||||||
const files = useFileStore((s) => s.files);
|
const sharedNumbering = useSettingsStore((s) => s.sharedNumbering);
|
||||||
|
|
||||||
const [text, setText] = useState("");
|
const [text, setText] = useState("");
|
||||||
const [initialized, setInitialized] = useState(false);
|
const [initialized, setInitialized] = useState(false);
|
||||||
|
const [intermediateStems, setIntermediateStems] = useState<string[]>([]);
|
||||||
|
|
||||||
// get selected file stems in display order
|
// get rules before this one in the pipeline
|
||||||
const fileMap = new Map(files.map((f) => [f.path, f]));
|
const ruleIndex = pipeline.findIndex((r) => r.id === ruleId);
|
||||||
const selectedStems = sortedFilePaths
|
const rulesBefore = ruleIndex > 0
|
||||||
.filter((p) => selectedFiles.has(p))
|
? pipeline.slice(0, ruleIndex).filter((r) => r.config.enabled).map((r) => r.config)
|
||||||
.map((p) => fileMap.get(p)?.stem ?? "");
|
: [];
|
||||||
|
|
||||||
|
// compute intermediate names (after previous steps, before this one)
|
||||||
|
const fetchIntermediateNames = useCallback(async () => {
|
||||||
|
if (!currentPath) return [];
|
||||||
|
const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null;
|
||||||
|
try {
|
||||||
|
const results = await invoke<PreviewResult[]>("preview_rename", {
|
||||||
|
rules: rulesBefore,
|
||||||
|
directory: currentPath,
|
||||||
|
filters,
|
||||||
|
selectedPaths: selected,
|
||||||
|
sharedNumbering,
|
||||||
|
});
|
||||||
|
// extract stems from the intermediate results
|
||||||
|
const stems = results.map((r) => {
|
||||||
|
const name = r.new_name;
|
||||||
|
const lastDot = name.lastIndexOf(".");
|
||||||
|
return lastDot > 0 ? name.substring(0, lastDot) : name;
|
||||||
|
});
|
||||||
|
setIntermediateStems(stems);
|
||||||
|
return stems;
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}, [currentPath, filters, selectedFiles, sharedNumbering, rulesBefore.length]);
|
||||||
|
|
||||||
|
// fetch intermediate names on mount
|
||||||
|
useEffect(() => {
|
||||||
|
fetchIntermediateNames();
|
||||||
|
}, [fetchIntermediateNames]);
|
||||||
|
|
||||||
|
const fileCount = intermediateStems.length;
|
||||||
const lineCount = text ? text.split("\n").length : 0;
|
const lineCount = text ? text.split("\n").length : 0;
|
||||||
const fileCount = selectedStems.length;
|
|
||||||
const mismatch = lineCount !== fileCount && text.length > 0;
|
const mismatch = lineCount !== fileCount && text.length > 0;
|
||||||
|
|
||||||
// populate textarea with current stems when first opened or when selection changes and user hasn't edited
|
// populate textarea with intermediate stems when first opened
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!rule) return;
|
if (!rule || initialized) return;
|
||||||
if (rule.names.length === 0 && !initialized) {
|
if (rule.names.length > 0) {
|
||||||
const joined = selectedStems.join("\n");
|
setText(rule.names.join("\n"));
|
||||||
|
setInitialized(true);
|
||||||
|
} else if (intermediateStems.length > 0) {
|
||||||
|
const joined = intermediateStems.join("\n");
|
||||||
setText(joined);
|
setText(joined);
|
||||||
setInitialized(true);
|
setInitialized(true);
|
||||||
}
|
}
|
||||||
}, [rule, selectedStems.length, initialized]);
|
}, [rule, intermediateStems.length, initialized]);
|
||||||
|
|
||||||
// sync rule names when first loaded with existing data
|
|
||||||
useEffect(() => {
|
|
||||||
if (!rule) return;
|
|
||||||
if (rule.names.length > 0 && !initialized) {
|
|
||||||
setText(rule.names.join("\n"));
|
|
||||||
setInitialized(true);
|
|
||||||
}
|
|
||||||
}, [rule, initialized]);
|
|
||||||
|
|
||||||
if (!rule) return null;
|
if (!rule) return null;
|
||||||
|
|
||||||
@@ -51,10 +81,11 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {
|
|||||||
updateRule(ruleId, { names });
|
updateRule(ruleId, { names });
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleLoad = () => {
|
const handleLoad = async () => {
|
||||||
const joined = selectedStems.join("\n");
|
const stems = await fetchIntermediateNames();
|
||||||
|
const joined = stems.join("\n");
|
||||||
setText(joined);
|
setText(joined);
|
||||||
updateRule(ruleId, { names: selectedStems });
|
updateRule(ruleId, { names: stems });
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -76,6 +107,7 @@ export function TextEditorConfig({ ruleId }: { ruleId: string }) {
|
|||||||
<textarea
|
<textarea
|
||||||
value={text}
|
value={text}
|
||||||
onChange={(e) => handleChange(e.target.value)}
|
onChange={(e) => handleChange(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.stopPropagation()}
|
||||||
spellCheck={false}
|
spellCheck={false}
|
||||||
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
|
className="w-full min-h-[360px] max-h-[600px] rounded-lg border border-input bg-transparent p-3 text-xs font-mono leading-relaxed resize-y outline-none focus:border-ring focus:ring-3 focus:ring-ring/50 dark:bg-input/30"
|
||||||
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
|
placeholder={"One filename per line...\nLine 1 = first selected file\nLine 2 = second selected file\n..."}
|
||||||
|
|||||||
@@ -182,12 +182,15 @@ export const useRuleStore = create<RuleState>((set, get) => ({
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { filters } = useFileStore.getState();
|
const { filters, selectedFiles } = useFileStore.getState();
|
||||||
|
const selected = selectedFiles.size > 0 ? Array.from(selectedFiles) : null;
|
||||||
|
const sharedNumbering = useSettingsStore.getState().sharedNumbering;
|
||||||
const results = await invoke<PreviewResult[]>("preview_rename", {
|
const results = await invoke<PreviewResult[]>("preview_rename", {
|
||||||
rules: activeRules,
|
rules: activeRules,
|
||||||
directory,
|
directory,
|
||||||
filters,
|
filters,
|
||||||
selectedPaths: null,
|
selectedPaths: selected,
|
||||||
|
sharedNumbering,
|
||||||
});
|
});
|
||||||
useFileStore.getState().setPreviewResults(results);
|
useFileStore.getState().setPreviewResults(results);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ interface SettingsState {
|
|||||||
skipReadOnly: boolean;
|
skipReadOnly: boolean;
|
||||||
conflictStrategy: ConflictStrategy;
|
conflictStrategy: ConflictStrategy;
|
||||||
caseSensitiveMatch: boolean;
|
caseSensitiveMatch: boolean;
|
||||||
|
sharedNumbering: boolean;
|
||||||
|
|
||||||
// pipeline
|
// pipeline
|
||||||
autoPreviewDelay: number;
|
autoPreviewDelay: number;
|
||||||
@@ -96,6 +97,7 @@ interface SettingsState {
|
|||||||
setSkipReadOnly: (v: boolean) => void;
|
setSkipReadOnly: (v: boolean) => void;
|
||||||
setConflictStrategy: (v: ConflictStrategy) => void;
|
setConflictStrategy: (v: ConflictStrategy) => void;
|
||||||
setCaseSensitiveMatch: (v: boolean) => void;
|
setCaseSensitiveMatch: (v: boolean) => void;
|
||||||
|
setSharedNumbering: (v: boolean) => void;
|
||||||
setAutoPreviewDelay: (v: number) => void;
|
setAutoPreviewDelay: (v: number) => void;
|
||||||
setShowDisabledRules: (v: DisabledRuleDisplay) => void;
|
setShowDisabledRules: (v: DisabledRuleDisplay) => void;
|
||||||
setCreateBackups: (v: boolean) => void;
|
setCreateBackups: (v: boolean) => void;
|
||||||
@@ -167,6 +169,7 @@ const defaults = {
|
|||||||
skipReadOnly: true,
|
skipReadOnly: true,
|
||||||
conflictStrategy: "suffix" as ConflictStrategy,
|
conflictStrategy: "suffix" as ConflictStrategy,
|
||||||
caseSensitiveMatch: false,
|
caseSensitiveMatch: false,
|
||||||
|
sharedNumbering: false,
|
||||||
autoPreviewDelay: 150,
|
autoPreviewDelay: 150,
|
||||||
showDisabledRules: "dimmed" as DisabledRuleDisplay,
|
showDisabledRules: "dimmed" as DisabledRuleDisplay,
|
||||||
createBackups: false,
|
createBackups: false,
|
||||||
@@ -218,6 +221,7 @@ export const useSettingsStore = create<SettingsState>()(
|
|||||||
setSkipReadOnly: (v) => set({ skipReadOnly: v }),
|
setSkipReadOnly: (v) => set({ skipReadOnly: v }),
|
||||||
setConflictStrategy: (v) => set({ conflictStrategy: v }),
|
setConflictStrategy: (v) => set({ conflictStrategy: v }),
|
||||||
setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }),
|
setCaseSensitiveMatch: (v) => set({ caseSensitiveMatch: v }),
|
||||||
|
setSharedNumbering: (v) => set({ sharedNumbering: v }),
|
||||||
setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }),
|
setAutoPreviewDelay: (v) => set({ autoPreviewDelay: v }),
|
||||||
setShowDisabledRules: (v) => set({ showDisabledRules: v }),
|
setShowDisabledRules: (v) => set({ showDisabledRules: v }),
|
||||||
setCreateBackups: (v) => set({ createBackups: v }),
|
setCreateBackups: (v) => set({ createBackups: v }),
|
||||||
|
|||||||
Reference in New Issue
Block a user