Merge pull request #48 from SteelDynamite/claude/jolly-mendel-Hwl4L
This commit is contained in:
commit
aceeac0442
|
|
@ -87,7 +87,7 @@ fn validate_workspace_path(path: &str) -> Result<(), String> {
|
||||||
#[cfg(windows)]
|
#[cfg(windows)]
|
||||||
{
|
{
|
||||||
let upper = normalized.to_uppercase();
|
let upper = normalized.to_uppercase();
|
||||||
if upper.len() <= 3 && upper.ends_with(":\\") || upper.ends_with(":") {
|
if upper.len() <= 3 && (upper.ends_with(":\\") || upper.ends_with(":")) {
|
||||||
return Err(format!("Cannot use drive root as workspace: {}", path));
|
return Err(format!("Cannot use drive root as workspace: {}", path));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,7 @@
|
||||||
let selectedMinute = $state(existing ? existing.getMinutes() : 0);
|
let selectedMinute = $state(existing ? existing.getMinutes() : 0);
|
||||||
let visible = $state(false);
|
let visible = $state(false);
|
||||||
|
|
||||||
let todayStr = `${now.getFullYear()}-${now.getMonth()}-${now.getDate()}`;
|
let todayStr = `${now.getFullYear()}-${now.getMonth() + 1}-${now.getDate()}`;
|
||||||
|
|
||||||
let daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
let daysInMonth = $derived(new Date(viewYear, viewMonth + 1, 0).getDate());
|
||||||
let firstDayOfWeek = $derived(new Date(viewYear, viewMonth, 1).getDay());
|
let firstDayOfWeek = $derived(new Date(viewYear, viewMonth, 1).getDay());
|
||||||
|
|
@ -53,7 +53,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
function isToday(day: number): boolean {
|
function isToday(day: number): boolean {
|
||||||
return `${viewYear}-${viewMonth}-${day}` === todayStr;
|
return `${viewYear}-${viewMonth + 1}-${day}` === todayStr;
|
||||||
}
|
}
|
||||||
|
|
||||||
function isSelected(day: number): boolean {
|
function isSelected(day: number): boolean {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
|
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
|
import { formatDateChip } from "../dateFormat";
|
||||||
import DateTimePicker from "./DateTimePicker.svelte";
|
import DateTimePicker from "./DateTimePicker.svelte";
|
||||||
|
|
||||||
let title = $state("");
|
let title = $state("");
|
||||||
|
|
@ -41,16 +42,6 @@
|
||||||
dateHasTime = hasTime;
|
dateHasTime = hasTime;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDateChip(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const today = new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = dayNames[d.getDay()];
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
const timePart = dateHasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
|
|
||||||
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
|
|
||||||
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
if (newTaskState.open) {
|
if (newTaskState.open) {
|
||||||
|
|
@ -105,7 +96,7 @@
|
||||||
{#if date}
|
{#if date}
|
||||||
<div class="flex items-center gap-1.5 rounded-full border border-border-light bg-black/5 px-3 py-1 text-sm dark:border-border-dark dark:bg-white/10">
|
<div class="flex items-center gap-1.5 rounded-full border border-border-light bg-black/5 px-3 py-1 text-sm dark:border-border-dark dark:bg-white/10">
|
||||||
<button type="button" onclick={() => (showDatePicker = true)} class="hover:opacity-70">
|
<button type="button" onclick={() => (showDatePicker = true)} class="hover:opacity-70">
|
||||||
{formatDateChip(date)}
|
{formatDateChip(date, dateHasTime)}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" onclick={() => (date = null)} class="opacity-40 hover:opacity-80">
|
<button type="button" onclick={() => (date = null)} class="opacity-40 hover:opacity-80">
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Task } from "../types";
|
import type { Task } from "../types";
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
|
import { formatDateChip } from "../dateFormat";
|
||||||
import DateTimePicker from "./DateTimePicker.svelte";
|
import DateTimePicker from "./DateTimePicker.svelte";
|
||||||
import ConfirmDialog from "./ConfirmDialog.svelte";
|
import ConfirmDialog from "./ConfirmDialog.svelte";
|
||||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||||
|
|
@ -118,17 +119,6 @@
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function formatDateChip(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const today = new Date();
|
|
||||||
const dayNames = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
||||||
const day = dayNames[d.getDay()];
|
|
||||||
const pad = (n: number) => String(n).padStart(2, "0");
|
|
||||||
const hasTime = task.has_time;
|
|
||||||
const timePart = hasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
|
|
||||||
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
|
|
||||||
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<!-- Header -->
|
<!-- Header -->
|
||||||
|
|
@ -240,7 +230,7 @@
|
||||||
{#if task.date}
|
{#if task.date}
|
||||||
<div class="flex items-center gap-1.5 rounded-full border border-border-light bg-black/5 px-3 py-1 text-sm dark:border-border-dark dark:bg-white/10">
|
<div class="flex items-center gap-1.5 rounded-full border border-border-light bg-black/5 px-3 py-1 text-sm dark:border-border-dark dark:bg-white/10">
|
||||||
<button onclick={() => (showDatePicker = true)} class="hover:opacity-70">
|
<button onclick={() => (showDatePicker = true)} class="hover:opacity-70">
|
||||||
{formatDateChip(task.date)}
|
{formatDateChip(task.date, task.has_time)}
|
||||||
</button>
|
</button>
|
||||||
<button onclick={() => handleDateChange(null)} class="opacity-40 hover:opacity-80">
|
<button onclick={() => handleDateChange(null)} class="opacity-40 hover:opacity-80">
|
||||||
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
<svg class="h-3.5 w-3.5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { Task } from "../types";
|
import type { Task } from "../types";
|
||||||
import { app } from "../stores/app.svelte";
|
import { app } from "../stores/app.svelte";
|
||||||
|
import { formatDateLabel } from "../dateFormat";
|
||||||
|
|
||||||
let { task, onopen, depth = 0, dateChipStyle = "normal", showSubtaskCount = true }: { task: Task; onopen?: (task: Task) => void; depth?: number; dateChipStyle?: "normal" | "overdue" | "hidden"; showSubtaskCount?: boolean } = $props();
|
let { task, onopen, depth = 0, dateChipStyle = "normal", showSubtaskCount = true }: { task: Task; onopen?: (task: Task) => void; depth?: number; dateChipStyle?: "normal" | "overdue" | "hidden"; showSubtaskCount?: boolean } = $props();
|
||||||
|
|
||||||
|
|
@ -77,15 +78,6 @@
|
||||||
swiping = false;
|
swiping = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatDate(iso: string): string {
|
|
||||||
const d = new Date(iso);
|
|
||||||
const today = new Date();
|
|
||||||
if (d.toDateString() === today.toDateString()) return "Today";
|
|
||||||
const tomorrow = new Date(today);
|
|
||||||
tomorrow.setDate(today.getDate() + 1);
|
|
||||||
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
|
||||||
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -153,11 +145,11 @@
|
||||||
{#if task.date && dateChipStyle !== "hidden"}
|
{#if task.date && dateChipStyle !== "hidden"}
|
||||||
{#if dateChipStyle === "overdue"}
|
{#if dateChipStyle === "overdue"}
|
||||||
<span class="mt-1 inline-block rounded-full border border-danger px-2 py-0.5 text-xs text-danger opacity-80">
|
<span class="mt-1 inline-block rounded-full border border-danger px-2 py-0.5 text-xs text-danger opacity-80">
|
||||||
{formatDate(task.date)}
|
{formatDateLabel(task.date)}
|
||||||
</span>
|
</span>
|
||||||
{:else}
|
{:else}
|
||||||
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
|
<span class="mt-1 inline-block rounded-full border border-border-light px-2 py-0.5 text-xs opacity-50 dark:border-border-dark">
|
||||||
{formatDate(task.date)}
|
{formatDateLabel(task.date)}
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
23
apps/tauri/src/lib/dateFormat.ts
Normal file
23
apps/tauri/src/lib/dateFormat.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
const DAY_NAMES = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
|
||||||
|
/** Format a date for display in chips (detail view, new task input). */
|
||||||
|
export function formatDateChip(iso: string, hasTime: boolean): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
const day = DAY_NAMES[d.getDay()];
|
||||||
|
const timePart = hasTime ? `, ${pad(d.getHours())}:${pad(d.getMinutes())}` : "";
|
||||||
|
if (d.toDateString() === today.toDateString()) return `Today${timePart}`;
|
||||||
|
return `${day}, ${pad(d.getDate())}/${pad(d.getMonth() + 1)}${timePart}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Format a date for compact display in task list items. */
|
||||||
|
export function formatDateLabel(iso: string): string {
|
||||||
|
const d = new Date(iso);
|
||||||
|
const today = new Date();
|
||||||
|
if (d.toDateString() === today.toDateString()) return "Today";
|
||||||
|
const tomorrow = new Date(today);
|
||||||
|
tomorrow.setDate(today.getDate() + 1);
|
||||||
|
if (d.toDateString() === tomorrow.toDateString()) return "Tomorrow";
|
||||||
|
return d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
|
||||||
|
}
|
||||||
|
|
@ -2,7 +2,7 @@ use anyhow::{Context, Result};
|
||||||
use crate::output;
|
use crate::output;
|
||||||
use crate::commands::get_repository;
|
use crate::commands::get_repository;
|
||||||
|
|
||||||
pub fn enable(list_name: String, workspace: Option<String>) -> Result<()> {
|
fn set_grouping(list_name: String, workspace: Option<String>, enabled: bool) -> Result<()> {
|
||||||
let (mut repo, _workspace_name) = get_repository(workspace)?;
|
let (mut repo, _workspace_name) = get_repository(workspace)?;
|
||||||
|
|
||||||
let lists = repo.get_lists()
|
let lists = repo.get_lists()
|
||||||
|
|
@ -12,28 +12,20 @@ pub fn enable(list_name: String, workspace: Option<String>) -> Result<()> {
|
||||||
.find(|l| l.title == list_name)
|
.find(|l| l.title == list_name)
|
||||||
.ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?;
|
.ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?;
|
||||||
|
|
||||||
repo.set_group_by_date(list.id, true)
|
let action = if enabled { "enable" } else { "disable" };
|
||||||
.context("Failed to enable grouping")?;
|
repo.set_group_by_date(list.id, enabled)
|
||||||
|
.context(format!("Failed to {} grouping", action))?;
|
||||||
|
|
||||||
output::success(&format!("Enabled group-by-date for list \"{}\"", list_name));
|
let past = if enabled { "Enabled" } else { "Disabled" };
|
||||||
|
output::success(&format!("{} group-by-date for list \"{}\"", past, list_name));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn enable(list_name: String, workspace: Option<String>) -> Result<()> {
|
||||||
|
set_grouping(list_name, workspace, true)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn disable(list_name: String, workspace: Option<String>) -> Result<()> {
|
pub fn disable(list_name: String, workspace: Option<String>) -> Result<()> {
|
||||||
let (mut repo, _workspace_name) = get_repository(workspace)?;
|
set_grouping(list_name, workspace, false)
|
||||||
|
|
||||||
let lists = repo.get_lists()
|
|
||||||
.context("Failed to get lists")?;
|
|
||||||
|
|
||||||
let list = lists.iter()
|
|
||||||
.find(|l| l.title == list_name)
|
|
||||||
.ok_or_else(|| anyhow::anyhow!("List '{}' not found", list_name))?;
|
|
||||||
|
|
||||||
repo.set_group_by_date(list.id, false)
|
|
||||||
.context("Failed to disable grouping")?;
|
|
||||||
|
|
||||||
output::success(&format!("Disabled group-by-date for list \"{}\"", list_name));
|
|
||||||
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,17 @@ use chrono::{DateTime, Utc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
use crate::output;
|
use crate::output;
|
||||||
use crate::commands::get_repository;
|
use crate::commands::get_repository;
|
||||||
|
use onyx_core::TaskList;
|
||||||
|
|
||||||
|
/// Find a task by ID across all lists, returning the list ID and cloned task.
|
||||||
|
fn find_task(lists: &[TaskList], task_id: Uuid) -> Option<(Uuid, Task)> {
|
||||||
|
for list in lists {
|
||||||
|
if let Some(task) = list.tasks.iter().find(|t| t.id == task_id) {
|
||||||
|
return Some((list.id, task.clone()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
pub fn add(title: String, list_name: Option<String>, date_str: Option<String>, workspace: Option<String>) -> Result<()> {
|
pub fn add(title: String, list_name: Option<String>, date_str: Option<String>, workspace: Option<String>) -> Result<()> {
|
||||||
let (mut repo, _workspace_name) = get_repository(workspace)?;
|
let (mut repo, _workspace_name) = get_repository(workspace)?;
|
||||||
|
|
@ -56,25 +67,15 @@ pub fn complete(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
let task_id = Uuid::parse_str(&task_id_str)
|
let task_id = Uuid::parse_str(&task_id_str)
|
||||||
.context("Invalid task ID")?;
|
.context("Invalid task ID")?;
|
||||||
|
|
||||||
// Find the task across all lists
|
|
||||||
let lists = repo.get_lists()?;
|
let lists = repo.get_lists()?;
|
||||||
let mut found = false;
|
let (list_id, mut task) = find_task(&lists, task_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Task not found: {}", task_id_str))?;
|
||||||
|
|
||||||
for list in lists {
|
task.complete();
|
||||||
if let Some(mut task) = list.tasks.iter().find(|t| t.id == task_id).cloned() {
|
repo.update_task(list_id, task.clone())
|
||||||
task.complete();
|
.context("Failed to update task")?;
|
||||||
repo.update_task(list.id, task.clone())
|
|
||||||
.context("Failed to update task")?;
|
|
||||||
|
|
||||||
output::success(&format!("Completed task \"{}\"", task.title));
|
output::success(&format!("Completed task \"{}\"", task.title));
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if !found {
|
|
||||||
anyhow::bail!("Task not found: {}", task_id_str);
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -85,37 +86,25 @@ pub fn delete(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
let task_id = Uuid::parse_str(&task_id_str)
|
let task_id = Uuid::parse_str(&task_id_str)
|
||||||
.context("Invalid task ID")?;
|
.context("Invalid task ID")?;
|
||||||
|
|
||||||
// Find the task across all lists
|
|
||||||
let lists = repo.get_lists()?;
|
let lists = repo.get_lists()?;
|
||||||
let mut found = false;
|
let (list_id, task) = find_task(&lists, task_id)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Task not found: {}", task_id_str))?;
|
||||||
|
|
||||||
for list in lists {
|
output::warning(&format!("This will delete task \"{}\"", task.title));
|
||||||
if let Some(task) = list.tasks.iter().find(|t| t.id == task_id) {
|
print!("Continue? (y/n): ");
|
||||||
let title = task.title.clone();
|
use std::io::{self, Write};
|
||||||
|
io::stdout().flush()?;
|
||||||
output::warning(&format!("This will delete task \"{}\"", title));
|
let mut input = String::new();
|
||||||
print!("Continue? (y/n): ");
|
io::stdin().read_line(&mut input)?;
|
||||||
use std::io::{self, Write};
|
if input.trim().to_lowercase() != "y" {
|
||||||
io::stdout().flush()?;
|
output::info("Cancelled");
|
||||||
let mut input = String::new();
|
return Ok(());
|
||||||
io::stdin().read_line(&mut input)?;
|
|
||||||
if input.trim().to_lowercase() != "y" {
|
|
||||||
output::info("Cancelled");
|
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
|
|
||||||
repo.delete_task(list.id, task_id)
|
|
||||||
.context("Failed to delete task")?;
|
|
||||||
|
|
||||||
output::success(&format!("Deleted task \"{}\"", title));
|
|
||||||
found = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if !found {
|
repo.delete_task(list_id, task_id)
|
||||||
anyhow::bail!("Task not found: {}", task_id_str);
|
.context("Failed to delete task")?;
|
||||||
}
|
|
||||||
|
output::success(&format!("Deleted task \"{}\"", task.title));
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
@ -126,23 +115,9 @@ pub fn edit(task_id_str: String, workspace: Option<String>) -> Result<()> {
|
||||||
let task_id = Uuid::parse_str(&task_id_str)
|
let task_id = Uuid::parse_str(&task_id_str)
|
||||||
.context("Invalid task ID")?;
|
.context("Invalid task ID")?;
|
||||||
|
|
||||||
// Find the task across all lists
|
|
||||||
let lists = repo.get_lists()?;
|
let lists = repo.get_lists()?;
|
||||||
let mut task_list_id = None;
|
let (list_id, task) = find_task(&lists, task_id)
|
||||||
let mut task_to_edit = None;
|
.ok_or_else(|| anyhow::anyhow!("Task not found: {}", task_id_str))?;
|
||||||
|
|
||||||
for list in lists {
|
|
||||||
if let Some(task) = list.tasks.iter().find(|t| t.id == task_id).cloned() {
|
|
||||||
task_list_id = Some(list.id);
|
|
||||||
task_to_edit = Some(task);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let (list_id, task) = match (task_list_id, task_to_edit) {
|
|
||||||
(Some(lid), Some(t)) => (lid, t),
|
|
||||||
_ => anyhow::bail!("Task not found: {}", task_id_str),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Create temporary file with task content
|
// Create temporary file with task content
|
||||||
let temp_dir = std::env::temp_dir();
|
let temp_dir = std::env::temp_dir();
|
||||||
|
|
|
||||||
|
|
@ -452,15 +452,7 @@ fn render_task_markdown(task: &Task) -> String {
|
||||||
|
|
||||||
/// Sanitize a string for use as a filesystem path component.
|
/// Sanitize a string for use as a filesystem path component.
|
||||||
fn sanitize_name(name: &str) -> String {
|
fn sanitize_name(name: &str) -> String {
|
||||||
let s: String = name.chars()
|
let s = crate::sanitize_filename(name);
|
||||||
.map(|c| match c {
|
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
||||||
'\0'..='\x1f' => '_',
|
|
||||||
_ => c,
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
.trim_matches(|c: char| c == '.' || c == ' ')
|
|
||||||
.to_string();
|
|
||||||
if s.is_empty() { "Untitled".to_string() } else { s }
|
if s.is_empty() { "Untitled".to_string() } else { s }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,30 @@ pub use models::{Task, TaskStatus, TaskList};
|
||||||
pub use repository::TaskRepository;
|
pub use repository::TaskRepository;
|
||||||
pub use config::{AppConfig, WorkspaceConfig};
|
pub use config::{AppConfig, WorkspaceConfig};
|
||||||
pub use error::{Error, Result};
|
pub use error::{Error, Result};
|
||||||
|
|
||||||
|
/// Sanitize a string for use as a filesystem path component.
|
||||||
|
/// Replaces filesystem-unsafe characters with underscores, trims leading/trailing
|
||||||
|
/// dots and spaces, and prefixes Windows reserved device names.
|
||||||
|
pub(crate) fn sanitize_filename(name: &str) -> String {
|
||||||
|
let sanitized: String = name.chars()
|
||||||
|
.map(|c| match c {
|
||||||
|
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
||||||
|
'\0'..='\x1f' => '_',
|
||||||
|
_ => c,
|
||||||
|
})
|
||||||
|
.collect::<String>()
|
||||||
|
.trim_matches(|c: char| c == '.' || c == ' ')
|
||||||
|
.to_string();
|
||||||
|
// Reject Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9)
|
||||||
|
let stem = sanitized.split('.').next().unwrap_or("").to_uppercase();
|
||||||
|
let is_reserved = matches!(stem.as_str(),
|
||||||
|
"CON" | "PRN" | "AUX" | "NUL"
|
||||||
|
| "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
|
||||||
|
| "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
|
||||||
|
);
|
||||||
|
if is_reserved {
|
||||||
|
format!("_{}", sanitized)
|
||||||
|
} else {
|
||||||
|
sanitized
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -237,27 +237,7 @@ impl FileSystemStorage {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sanitize_filename(name: &str) -> String {
|
fn sanitize_filename(name: &str) -> String {
|
||||||
let sanitized: String = name.chars()
|
crate::sanitize_filename(name)
|
||||||
.map(|c| match c {
|
|
||||||
'/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
|
|
||||||
'\0'..='\x1f' => '_',
|
|
||||||
_ => c,
|
|
||||||
})
|
|
||||||
.collect::<String>()
|
|
||||||
.trim_matches(|c: char| c == '.' || c == ' ')
|
|
||||||
.to_string();
|
|
||||||
// Reject Windows reserved device names (CON, PRN, AUX, NUL, COM0-9, LPT0-9)
|
|
||||||
let stem = sanitized.split('.').next().unwrap_or("").to_uppercase();
|
|
||||||
let is_reserved = matches!(stem.as_str(),
|
|
||||||
"CON" | "PRN" | "AUX" | "NUL"
|
|
||||||
| "COM0" | "COM1" | "COM2" | "COM3" | "COM4" | "COM5" | "COM6" | "COM7" | "COM8" | "COM9"
|
|
||||||
| "LPT0" | "LPT1" | "LPT2" | "LPT3" | "LPT4" | "LPT5" | "LPT6" | "LPT7" | "LPT8" | "LPT9"
|
|
||||||
);
|
|
||||||
if is_reserved {
|
|
||||||
format!("_{}", sanitized)
|
|
||||||
} else {
|
|
||||||
sanitized
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf {
|
fn task_file_path(&self, list_dir: &Path, task: &Task) -> PathBuf {
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,10 @@ pub struct RemoteFileInfo {
|
||||||
|
|
||||||
/// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop.
|
/// WebDAV client wrapping reqwest with basic auth. Credentials are zeroized on drop.
|
||||||
pub struct WebDavClient {
|
pub struct WebDavClient {
|
||||||
_client: Client,
|
client: Client,
|
||||||
_base_url: String,
|
base_url: String,
|
||||||
_username: Zeroizing<String>,
|
username: Zeroizing<String>,
|
||||||
_password: Zeroizing<String>,
|
password: Zeroizing<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebDavClient {
|
impl WebDavClient {
|
||||||
|
|
@ -43,17 +43,17 @@ impl WebDavClient {
|
||||||
.build()
|
.build()
|
||||||
.map_err(|e| Error::WebDav(format!("Failed to build HTTP client: {}", e)))?;
|
.map_err(|e| Error::WebDav(format!("Failed to build HTTP client: {}", e)))?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
_client: client,
|
client,
|
||||||
_base_url: base_url,
|
base_url,
|
||||||
_username: Zeroizing::new(username.to_string()),
|
username: Zeroizing::new(username.to_string()),
|
||||||
_password: Zeroizing::new(password.to_string()),
|
password: Zeroizing::new(password.to_string()),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
fn full_url(&self, path: &str) -> String {
|
fn full_url(&self, path: &str) -> String {
|
||||||
let path = path.trim_start_matches('/');
|
let path = path.trim_start_matches('/');
|
||||||
if path.is_empty() {
|
if path.is_empty() {
|
||||||
self._base_url.clone()
|
self.base_url.clone()
|
||||||
} else {
|
} else {
|
||||||
// Percent-encode path segments while preserving '/'
|
// Percent-encode path segments while preserving '/'
|
||||||
let encoded: String = path
|
let encoded: String = path
|
||||||
|
|
@ -61,15 +61,15 @@ impl WebDavClient {
|
||||||
.map(percent_encode)
|
.map(percent_encode)
|
||||||
.collect::<Vec<_>>()
|
.collect::<Vec<_>>()
|
||||||
.join("/");
|
.join("/");
|
||||||
format!("{}/{}", self._base_url, encoded)
|
format!("{}/{}", self.base_url, encoded)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Test connection by issuing a PROPFIND depth 0 on the root.
|
/// Test connection by issuing a PROPFIND depth 0 on the root.
|
||||||
pub async fn test_connection(&self) -> Result<()> {
|
pub async fn test_connection(&self) -> Result<()> {
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method"), &self._base_url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method"), &self.base_url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.header("Depth", "0")
|
.header("Depth", "0")
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.body(PROPFIND_BODY)
|
.body(PROPFIND_BODY)
|
||||||
|
|
@ -89,9 +89,9 @@ impl WebDavClient {
|
||||||
/// List files at a given path using PROPFIND depth 1.
|
/// List files at a given path using PROPFIND depth 1.
|
||||||
pub async fn list_files(&self, path: &str) -> Result<Vec<RemoteFileInfo>> {
|
pub async fn list_files(&self, path: &str) -> Result<Vec<RemoteFileInfo>> {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.request(reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method"), &url)
|
.request(reqwest::Method::from_bytes(b"PROPFIND").expect("PROPFIND is a valid HTTP method"), &url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.header("Depth", "1")
|
.header("Depth", "1")
|
||||||
.header("Content-Type", "application/xml")
|
.header("Content-Type", "application/xml")
|
||||||
.body(PROPFIND_BODY)
|
.body(PROPFIND_BODY)
|
||||||
|
|
@ -113,15 +113,15 @@ impl WebDavClient {
|
||||||
return Err(Error::WebDav("PROPFIND response too large (>10MB)".into()));
|
return Err(Error::WebDav("PROPFIND response too large (>10MB)".into()));
|
||||||
}
|
}
|
||||||
let body = String::from_utf8_lossy(&bytes);
|
let body = String::from_utf8_lossy(&bytes);
|
||||||
parse_propfind_response(&body, &self._base_url, path)
|
parse_propfind_response(&body, &self.base_url, path)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Download a file's contents.
|
/// Download a file's contents.
|
||||||
pub async fn get_file(&self, path: &str) -> Result<Vec<u8>> {
|
pub async fn get_file(&self, path: &str) -> Result<Vec<u8>> {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.get(&url)
|
.get(&url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -147,9 +147,9 @@ impl WebDavClient {
|
||||||
/// Upload a file.
|
/// Upload a file.
|
||||||
pub async fn put_file(&self, path: &str, content: Vec<u8>) -> Result<()> {
|
pub async fn put_file(&self, path: &str, content: Vec<u8>) -> Result<()> {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.put(&url)
|
.put(&url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.body(content)
|
.body(content)
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
@ -164,9 +164,9 @@ impl WebDavClient {
|
||||||
/// Delete a remote file.
|
/// Delete a remote file.
|
||||||
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
pub async fn delete_file(&self, path: &str) -> Result<()> {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.delete(&url)
|
.delete(&url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -183,9 +183,9 @@ impl WebDavClient {
|
||||||
/// Create a directory via MKCOL.
|
/// Create a directory via MKCOL.
|
||||||
pub async fn create_dir(&self, path: &str) -> Result<()> {
|
pub async fn create_dir(&self, path: &str) -> Result<()> {
|
||||||
let url = self.full_url(path);
|
let url = self.full_url(path);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.request(reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"), &url)
|
.request(reqwest::Method::from_bytes(b"MKCOL").expect("MKCOL is a valid HTTP method"), &url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -203,9 +203,9 @@ impl WebDavClient {
|
||||||
pub async fn move_resource(&self, from: &str, to: &str) -> Result<()> {
|
pub async fn move_resource(&self, from: &str, to: &str) -> Result<()> {
|
||||||
let from_url = self.full_url(from);
|
let from_url = self.full_url(from);
|
||||||
let to_url = self.full_url(to);
|
let to_url = self.full_url(to);
|
||||||
let resp = self._client
|
let resp = self.client
|
||||||
.request(reqwest::Method::from_bytes(b"MOVE").expect("MOVE is a valid HTTP method"), &from_url)
|
.request(reqwest::Method::from_bytes(b"MOVE").expect("MOVE is a valid HTTP method"), &from_url)
|
||||||
.basic_auth(self._username.as_str(), Some(self._password.as_str()))
|
.basic_auth(self.username.as_str(), Some(self.password.as_str()))
|
||||||
.header("Destination", &to_url)
|
.header("Destination", &to_url)
|
||||||
.header("Overwrite", "F")
|
.header("Overwrite", "F")
|
||||||
.send()
|
.send()
|
||||||
|
|
@ -448,12 +448,12 @@ pub fn store_credentials(domain: &str, username: &str, password: &str) -> Result
|
||||||
|
|
||||||
let user_entry = keyring::Entry::new(&service, "username")
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
user_entry.set_password(username)
|
user_entry.setpassword(username)
|
||||||
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to store username: {}", e)))?;
|
||||||
|
|
||||||
let pass_entry = keyring::Entry::new(&scoped_service, "password")
|
let pass_entry = keyring::Entry::new(&scoped_service, "password")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
pass_entry.set_password(password)
|
pass_entry.setpassword(password)
|
||||||
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to store password: {}", e)))?;
|
||||||
|
|
||||||
// Clean up legacy unscoped password entry if present
|
// Clean up legacy unscoped password entry if present
|
||||||
|
|
@ -478,18 +478,18 @@ pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<St
|
||||||
let user_entry = keyring::Entry::new(&service, "username")
|
let user_entry = keyring::Entry::new(&service, "username")
|
||||||
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
.map_err(|e| Error::Credential(format!("Failed to create keyring entry: {}", e)))?;
|
||||||
|
|
||||||
if let Ok(user) = user_entry.get_password() {
|
if let Ok(user) = user_entry.getpassword() {
|
||||||
// Try scoped password key first (domain+username), fall back to legacy unscoped key
|
// Try scoped password key first (domain+username), fall back to legacy unscoped key
|
||||||
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
||||||
let found = keyring::Entry::new(&scoped_service, "password")
|
let found = keyring::Entry::new(&scoped_service, "password")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|e| e.get_password().ok())
|
.and_then(|e| e.getpassword().ok())
|
||||||
.map(|p| (p, false))
|
.map(|p| (p, false))
|
||||||
.or_else(|| {
|
.or_else(|| {
|
||||||
// Migration fallback: try legacy unscoped password entry
|
// Migration fallback: try legacy unscoped password entry
|
||||||
keyring::Entry::new(&service, "password")
|
keyring::Entry::new(&service, "password")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|e| e.get_password().ok())
|
.and_then(|e| e.getpassword().ok())
|
||||||
.map(|p| (p, true))
|
.map(|p| (p, true))
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -497,7 +497,7 @@ pub fn load_credentials(domain: &str) -> Result<(Zeroizing<String>, Zeroizing<St
|
||||||
// Auto-migrate legacy credentials to scoped format
|
// Auto-migrate legacy credentials to scoped format
|
||||||
if needs_migration {
|
if needs_migration {
|
||||||
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
if let Ok(entry) = keyring::Entry::new(&scoped_service, "password") {
|
||||||
let _ = entry.set_password(&pass);
|
let _ = entry.setpassword(&pass);
|
||||||
}
|
}
|
||||||
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
if let Ok(legacy) = keyring::Entry::new(&service, "password") {
|
||||||
let _ = legacy.delete_credential();
|
let _ = legacy.delete_credential();
|
||||||
|
|
@ -547,7 +547,7 @@ pub fn delete_credentials(domain: &str) -> Result<()> {
|
||||||
// Load username first so we can delete the scoped password entry
|
// Load username first so we can delete the scoped password entry
|
||||||
let username = keyring::Entry::new(&service, "username")
|
let username = keyring::Entry::new(&service, "username")
|
||||||
.ok()
|
.ok()
|
||||||
.and_then(|e| e.get_password().ok());
|
.and_then(|e| e.getpassword().ok());
|
||||||
|
|
||||||
if let Some(user) = &username {
|
if let Some(user) = &username {
|
||||||
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
let scoped_service = format!("com.onyx.webdav.{}::{}", domain, user);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue