added bulk import tool

This commit is contained in:
2026-02-09 15:13:51 -06:00
parent 6192a1dc32
commit 414eead28d
2 changed files with 397 additions and 1 deletions
+367
View File
@@ -0,0 +1,367 @@
import { type Component, createSignal, For, Show } from "solid-js";
import { addTask, calculateDateFromUrgency } from "@/store";
import { Button } from "@/components/ui/button";
import { TagPicker } from "@/components/TagPicker";
import { Badge } from "@/components/ui/badge";
import { toast } from "solid-sonner";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS, SIZE_OPTIONS } from "@/lib/constants";
import { X, ArrowRight, Upload, Loader2 } from "lucide-solid";
interface ImportedTask {
id: number;
title: string;
priority: string;
urgency: string;
size: string;
tags: string[];
}
/**
* Strips common list prefixes from a line of text:
* - Checkboxes: ☐ ☑ ✅ ✓ ✗ ✘ ❌ - [ ] - [x] - [X]
* - Bullets: • ‣ ◦ ▪ ▸ ► ○ ● - * +
* - Numbered: 1. 2) 3 -
*/
const stripPrefix = (line: string): string => {
// Regex matches everything from the start of the string that is NOT a letter or a number
// \p{L} matches any kind of letter from any language
// \p{N} matches any kind of numeric character
// The 'u' flag is required for unicode property escapes
return line.replace(/^[^\p{L}\p{N}]*/u, "").trim();
};
export const ImportTool: Component = () => {
const [step, setStep] = createSignal<"paste" | "configure">("paste");
const [rawText, setRawText] = createSignal("");
const [tasks, setTasks] = createSignal<ImportedTask[]>([]);
const [importing, setImporting] = createSignal(false);
const [importProgress, setImportProgress] = createSignal(0);
// Bulk defaults
const [bulkPriority, setBulkPriority] = createSignal("5");
const [bulkUrgency, setBulkUrgency] = createSignal("5");
const [bulkSize, setBulkSize] = createSignal("5");
const [bulkTags, setBulkTags] = createSignal<string[]>([]);
const parseText = () => {
const lines = rawText()
.split("\n")
.map(l => stripPrefix(l))
.filter(l => l.length > 0);
if (lines.length === 0) {
toast.error("No tasks found in pasted text.");
return;
}
const parsed: ImportedTask[] = lines.map((title, i) => ({
id: i,
title,
priority: bulkPriority(),
urgency: bulkUrgency(),
size: bulkSize(),
tags: [...bulkTags()]
}));
setTasks(parsed);
setStep("configure");
};
const applyBulkDefaults = () => {
setTasks(prev =>
prev.map(t => ({
...t,
priority: bulkPriority(),
urgency: bulkUrgency(),
size: bulkSize(),
tags: [...bulkTags()]
}))
);
};
const updateTask = (id: number, field: keyof ImportedTask, value: any) => {
setTasks(prev => prev.map(t => t.id === id ? { ...t, [field]: value } : t));
};
const removeTask = (id: number) => {
setTasks(prev => prev.filter(t => t.id !== id));
};
const handleImport = async () => {
const taskList = tasks();
if (taskList.length === 0) return;
setImporting(true);
setImportProgress(0);
let successCount = 0;
for (let i = 0; i < taskList.length; i++) {
const t = taskList[i];
try {
const urgencyLevel = parseInt(t.urgency);
const dueDate = new Date(calculateDateFromUrgency(urgencyLevel));
await addTask(t.title, {
priority: parseInt(t.priority),
dueDate,
tags: t.tags.length > 0 ? t.tags : ["work"],
size: parseInt(t.size)
});
successCount++;
} catch (err) {
console.error(`Failed to import task: ${t.title}`, err);
}
setImportProgress(i + 1);
}
setImporting(false);
toast.success(`Imported ${successCount} task${successCount !== 1 ? "s" : ""} successfully!`);
// Reset
setRawText("");
setTasks([]);
setStep("paste");
setBulkPriority("5");
setBulkUrgency("5");
setBulkSize("5");
setBulkTags([]);
};
const reset = () => {
setStep("paste");
setTasks([]);
};
return (
<div class="space-y-4">
<Show when={step() === "paste"}>
<div class="space-y-3 animate-in fade-in duration-300">
<div class="space-y-1.5">
<label class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground ml-1">
Paste your task list
</label>
<textarea
value={rawText()}
onInput={(e) => setRawText(e.currentTarget.value)}
placeholder={"Paste tasks from Apple Notes, a to-do app, or any text...\n\nExamples:\n- [ ] Buy groceries\n- [x] Walk the dog\n• Clean kitchen\n1. Call dentist\n☐ Fix bug"}
class="flex w-full rounded-xl border border-input bg-background px-4 py-3 text-sm shadow-sm transition-colors placeholder:text-muted-foreground/60 focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring min-h-[180px] resize-y font-mono leading-relaxed"
/>
</div>
<div class="flex items-center justify-between">
<p class="text-xs text-muted-foreground">
{rawText().split("\n").filter(l => stripPrefix(l).length > 0).length} task(s) detected
</p>
<Button
size="sm"
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[10px] h-9 px-4"
disabled={!rawText().trim()}
onClick={parseText}
>
<ArrowRight size={14} />
Parse & Configure
</Button>
</div>
</div>
</Show>
<Show when={step() === "configure"}>
<div class="space-y-4 animate-in fade-in slide-in-from-bottom-2 duration-300">
{/* Bulk defaults */}
<div class="p-3 rounded-xl bg-muted/30 border border-border/50 space-y-3">
<div class="flex items-center justify-between">
<p class="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
Bulk Defaults
</p>
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] font-bold uppercase tracking-wider rounded-lg"
onClick={applyBulkDefaults}
>
Apply to All
</Button>
</div>
<div class="grid grid-cols-2 sm:grid-cols-4 gap-2">
<div class="space-y-1">
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Priority</label>
<select
value={bulkPriority()}
onChange={(e) => setBulkPriority(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={PRIORITY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Urgency</label>
<select
value={bulkUrgency()}
onChange={(e) => setBulkUrgency(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={URGENCY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Size</label>
<select
value={bulkSize()}
onChange={(e) => setBulkSize(e.currentTarget.value)}
class="flex h-8 w-full rounded-lg border border-input bg-background px-2 py-1 text-xs shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
>
<For each={SIZE_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
</div>
<div class="space-y-1">
<label class="text-[9px] font-bold uppercase tracking-wider text-muted-foreground/70 ml-0.5">Tags</label>
<div class="flex items-center gap-1 flex-wrap">
<TagPicker selectedTags={bulkTags()} onTagsChange={setBulkTags} />
<For each={bulkTags()}>
{(tag) => (
<Badge
variant="secondary"
class="h-6 px-1.5 text-[10px] gap-1 cursor-pointer hover:bg-destructive/10"
onClick={() => setBulkTags(bulkTags().filter(t => t !== tag))}
>
{tag}
<X size={10} />
</Badge>
)}
</For>
</div>
</div>
</div>
</div>
{/* Task list */}
<div class="space-y-2">
<div class="flex items-center justify-between">
<p class="text-xs font-bold text-muted-foreground">
{tasks().length} task{tasks().length !== 1 ? "s" : ""} to import
</p>
<Button
variant="ghost"
size="sm"
class="h-7 text-[10px] font-bold uppercase tracking-wider rounded-lg text-muted-foreground"
onClick={reset}
>
Back
</Button>
</div>
<div class="space-y-2 max-h-[400px] overflow-y-auto pr-1">
<For each={tasks()}>
{(task) => (
<div class="p-3 rounded-xl bg-muted/20 border border-border/40 space-y-2.5 group hover:bg-muted/30 transition-colors">
{/* Title row */}
<div class="flex items-center gap-2">
<div class="w-1.5 h-1.5 rounded-full bg-primary shrink-0" />
<input
value={task.title}
onInput={(e) => updateTask(task.id, "title", e.currentTarget.value)}
class="flex-1 bg-transparent text-sm font-medium border-none outline-none focus:underline decoration-primary/40 underline-offset-4 min-w-0"
/>
<Button
variant="ghost"
size="icon"
class="h-7 w-7 shrink-0 opacity-0 group-hover:opacity-100 hover:bg-destructive/10 hover:text-destructive rounded-lg transition-all"
onClick={() => removeTask(task.id)}
>
<X size={12} />
</Button>
</div>
{/* Controls row */}
<div class="flex flex-wrap items-center gap-2">
<select
value={task.priority}
onChange={(e) => updateTask(task.id, "priority", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Priority"
>
<For each={PRIORITY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<select
value={task.urgency}
onChange={(e) => updateTask(task.id, "urgency", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Urgency"
>
<For each={URGENCY_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<select
value={task.size}
onChange={(e) => updateTask(task.id, "size", e.currentTarget.value)}
class="h-7 rounded-lg border border-input bg-background px-1.5 text-[11px] shadow-sm outline-none focus:ring-1 focus:ring-ring appearance-none"
title="Size"
>
<For each={SIZE_OPTIONS}>
{(opt) => <option value={opt.value}>{opt.label}</option>}
</For>
</select>
<div class="flex items-center gap-1 flex-wrap">
<TagPicker
selectedTags={task.tags}
onTagsChange={(tags) => updateTask(task.id, "tags", tags)}
/>
<For each={task.tags}>
{(tag) => (
<Badge
variant="secondary"
class="h-6 px-1.5 text-[10px] gap-0.5 cursor-pointer hover:bg-destructive/10"
onClick={() => updateTask(task.id, "tags", task.tags.filter(t => t !== tag))}
>
{tag}
<X size={8} />
</Badge>
)}
</For>
</div>
</div>
</div>
)}
</For>
</div>
</div>
{/* Import button */}
<div class="flex items-center justify-between pt-2 border-t border-border/40">
<Show when={importing()}>
<div class="flex items-center gap-2 text-xs text-muted-foreground">
<Loader2 size={14} class="animate-spin" />
Importing {importProgress()}/{tasks().length}...
</div>
</Show>
<Show when={!importing()}>
<div />
</Show>
<Button
size="sm"
class="gap-2 rounded-xl font-bold uppercase tracking-wider text-[10px] h-9 px-5"
disabled={tasks().length === 0 || importing()}
onClick={handleImport}
>
<Upload size={14} />
Import {tasks().length} Task{tasks().length !== 1 ? "s" : ""}
</Button>
</div>
</div>
</Show>
</div>
);
};
+30 -1
View File
@@ -2,7 +2,7 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-j
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store"; import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag } from "lucide-solid"; import { Trash2, Undo2, ArrowLeftRight, Hash, Copy, Plus, ChevronDown, ChevronRight, Type, Share2, Users, Tag, Upload } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker"; import { TagPicker } from "@/components/TagPicker";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -13,6 +13,7 @@ import { pb } from "@/lib/pocketbase";
import { createEffect } from "solid-js"; import { createEffect } from "solid-js";
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
const ImportTool = lazy(() => import("../components/ImportTool").then(m => ({ default: m.ImportTool })));
export const SettingsView: Component = () => { export const SettingsView: Component = () => {
const { resolvedTheme } = useTheme(); const { resolvedTheme } = useTheme();
@@ -20,6 +21,7 @@ export const SettingsView: Component = () => {
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false); const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
const [isTrashOpen, setIsTrashOpen] = createSignal(false); const [isTrashOpen, setIsTrashOpen] = createSignal(false);
const [isSharingOpen, setIsSharingOpen] = createSignal(false); const [isSharingOpen, setIsSharingOpen] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false);
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null); const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
const [shareUserId, setShareUserId] = createSignal(""); const [shareUserId, setShareUserId] = createSignal("");
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all'); const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
@@ -561,6 +563,33 @@ export const SettingsView: Component = () => {
</Show> </Show>
</section> </section>
{/* Import Tasks */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
<div
class="flex items-center justify-between cursor-pointer group"
onClick={() => setIsImportOpen(!isImportOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Upload size={16} />
Import Tasks
</h3>
<p class="text-sm text-muted-foreground">Paste a list of tasks from another app to import them.</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isImportOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isImportOpen()}>
<div class="pt-2 animate-in fade-in slide-in-from-top-2 duration-300">
<Suspense fallback={<div class="h-20 animate-pulse bg-muted/20 rounded-lg" />}>
<ImportTool />
</Suspense>
</div>
</Show>
</section>
{/* Trash Manager */} {/* Trash Manager */}
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden"> <section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
<div <div