added bulk import tool
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
@@ -2,7 +2,7 @@ import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-j
|
||||
import { ThemeToggle } from "../components/ThemeToggle";
|
||||
import { store, restoreTask, deleteTaskPermanently, setMatrixScaleDays, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule } from "@/store";
|
||||
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 { cn } from "@/lib/utils";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -13,6 +13,7 @@ import { pb } from "@/lib/pocketbase";
|
||||
import { createEffect } from "solid-js";
|
||||
|
||||
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 = () => {
|
||||
const { resolvedTheme } = useTheme();
|
||||
@@ -20,6 +21,7 @@ export const SettingsView: Component = () => {
|
||||
const [isTemplatesOpen, setIsTemplatesOpen] = createSignal(false);
|
||||
const [isTrashOpen, setIsTrashOpen] = createSignal(false);
|
||||
const [isSharingOpen, setIsSharingOpen] = createSignal(false);
|
||||
const [isImportOpen, setIsImportOpen] = createSignal(false);
|
||||
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
|
||||
const [shareUserId, setShareUserId] = createSignal("");
|
||||
const [shareType, setShareType] = createSignal<'all' | 'tag'>('all');
|
||||
@@ -561,6 +563,33 @@ export const SettingsView: Component = () => {
|
||||
</Show>
|
||||
</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 */}
|
||||
<section class="p-4 sm:p-5 rounded-2xl border border-border bg-card shadow-sm space-y-4 overflow-hidden">
|
||||
<div
|
||||
|
||||
Reference in New Issue
Block a user