diff --git a/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs new file mode 100644 index 000000000..b439b568f --- /dev/null +++ b/backend/src/Taskdeck.Api/Controllers/NoteImportController.cs @@ -0,0 +1,103 @@ +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.AspNetCore.RateLimiting; +using Taskdeck.Api.Contracts; +using Taskdeck.Api.Extensions; +using Taskdeck.Api.RateLimiting; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Interfaces; +using Taskdeck.Application.Services; + +namespace Taskdeck.Api.Controllers; + +/// +/// Note-style import endpoints for markdown files and web clips. +/// All imported content routes through the standard capture pipeline — +/// no direct board mutations occur here. +/// +[ApiController] +[Authorize] +[Route("api/import/notes")] +[Produces("application/json")] +public class NoteImportController : AuthenticatedControllerBase +{ + private readonly INoteImportService _noteImportService; + + public NoteImportController( + INoteImportService noteImportService, + IUserContext userContext) + : base(userContext) + { + _noteImportService = noteImportService; + } + + /// + /// Import a markdown file. The content is parsed into sections and + /// each section becomes a capture item in the standard pipeline. + /// + /// Markdown import request with filename and content. + /// Cancellation token. + /// Import result with created capture item IDs. + /// Markdown imported successfully — capture items created. + /// Validation error (empty content, oversized file, etc.). + /// Authentication required. + /// Rate limit exceeded. + [HttpPost("markdown")] + [EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)] + [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task ImportMarkdown( + [FromBody] MarkdownImportRequestDto? dto, + CancellationToken cancellationToken) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + if (dto == null) + { + return BadRequest(new ApiErrorResponse( + "VALIDATION_ERROR", + "Request body is required")); + } + + var result = await _noteImportService.ImportMarkdownAsync(userId, dto, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } + + /// + /// Import a web clip (URL + content snippet). Creates a single capture + /// item with the URL preserved as source provenance. + /// + /// Web clip import request with URL, content, and optional title. + /// Cancellation token. + /// Import result with created capture item ID. + /// Web clip imported successfully — capture item created. + /// Validation error (invalid URL, empty content, etc.). + /// Authentication required. + /// Rate limit exceeded. + [HttpPost("webclip")] + [EnableRateLimiting(RateLimitingPolicyNames.NoteImportPerUser)] + [ProducesResponseType(typeof(NoteImportResultDto), StatusCodes.Status200OK)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status400BadRequest)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status401Unauthorized)] + [ProducesResponseType(typeof(ApiErrorResponse), StatusCodes.Status429TooManyRequests)] + public async Task ImportWebClip( + [FromBody] WebClipImportRequestDto? dto, + CancellationToken cancellationToken) + { + if (!TryGetCurrentUserId(out var userId, out var errorResult)) + return errorResult!; + + if (dto == null) + { + return BadRequest(new ApiErrorResponse( + "VALIDATION_ERROR", + "Request body is required")); + } + + var result = await _noteImportService.ImportWebClipAsync(userId, dto, cancellationToken); + return result.IsSuccess ? Ok(result.Value) : result.ToErrorActionResult(); + } +} diff --git a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs index 46cec28f0..6c7040803 100644 --- a/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/ApplicationServiceRegistration.cs @@ -32,6 +32,7 @@ public static IServiceCollection AddApplicationServices(this IServiceCollection services.AddScoped(); services.AddScoped(); services.AddScoped(); + services.AddScoped(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => sp.GetRequiredService()); diff --git a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs index 485c6d5b2..40fd6eace 100644 --- a/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs +++ b/backend/src/Taskdeck.Api/Extensions/RateLimitingRegistration.cs @@ -73,6 +73,12 @@ await context.HttpContext.Response.WriteAsJsonAsync( return BuildFixedWindowPartition(partitionKey, settings.CaptureWritePerUser); }); + options.AddPolicy(RateLimitingPolicyNames.NoteImportPerUser, httpContext => + { + var partitionKey = $"note-import-user:{ResolveUserOrClientIdentifier(httpContext)}"; + return BuildFixedWindowPartition(partitionKey, settings.NoteImportPerUser); + }); + options.AddPolicy(RateLimitingPolicyNames.McpPerApiKey, httpContext => { // Partition by API key user or fall back to IP for unauthenticated attempts. diff --git a/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs b/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs index fa6faa433..78b9c02c6 100644 --- a/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs +++ b/backend/src/Taskdeck.Api/RateLimiting/RateLimitingPolicyNames.cs @@ -5,5 +5,6 @@ public static class RateLimitingPolicyNames public const string AuthPerIp = "AuthPerIp"; public const string HotPathPerUser = "HotPathPerUser"; public const string CaptureWritePerUser = "CaptureWritePerUser"; + public const string NoteImportPerUser = "NoteImportPerUser"; public const string McpPerApiKey = "McpPerApiKey"; } diff --git a/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs new file mode 100644 index 000000000..80112a749 --- /dev/null +++ b/backend/src/Taskdeck.Application/DTOs/NoteImportDtos.cs @@ -0,0 +1,49 @@ +namespace Taskdeck.Application.DTOs; + +/// +/// Request to import a markdown file as one or more capture items. +/// The content is parsed, split into logical sections, and routed +/// through the standard capture pipeline. +/// +public sealed record MarkdownImportRequestDto( + string FileName, + string Content, + Guid? BoardId = null); + +/// +/// Request to import a web clip (URL + content snippet) as a capture item. +/// The content is routed through the standard capture pipeline with +/// source provenance preserved. +/// +public sealed record WebClipImportRequestDto( + string Url, + string Content, + string? Title = null, + Guid? BoardId = null); + +/// +/// Result of a note-style import operation. +/// +public sealed record NoteImportResultDto( + int ItemsCreated, + IReadOnlyList Items, + IReadOnlyList? Warnings = null, + IReadOnlyList? Errors = null); + +/// +/// Error detail for a section that failed to import. +/// +public sealed record NoteImportItemErrorDto( + int SectionIndex, + string? Heading, + string ErrorCode, + string ErrorMessage); + +/// +/// Result for a single capture item created from a note import. +/// +public sealed record NoteImportItemResultDto( + Guid CaptureItemId, + string TextExcerpt, + string SourceType, + string? SourceRef); diff --git a/backend/src/Taskdeck.Application/Services/INoteImportService.cs b/backend/src/Taskdeck.Application/Services/INoteImportService.cs new file mode 100644 index 000000000..1776d7286 --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/INoteImportService.cs @@ -0,0 +1,28 @@ +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; + +namespace Taskdeck.Application.Services; + +/// +/// Handles note-style import (markdown files, web clips) by routing +/// imported content through the standard capture pipeline. +/// No direct board mutations — all content enters the capture → triage → proposal flow. +/// +public interface INoteImportService +{ + /// + /// Parses a markdown file and creates capture items for each logical section. + /// + Task> ImportMarkdownAsync( + Guid userId, + MarkdownImportRequestDto request, + CancellationToken cancellationToken = default); + + /// + /// Creates a capture item from a web clip (URL + content snippet). + /// + Task> ImportWebClipAsync( + Guid userId, + WebClipImportRequestDto request, + CancellationToken cancellationToken = default); +} diff --git a/backend/src/Taskdeck.Application/Services/NoteImportService.cs b/backend/src/Taskdeck.Application/Services/NoteImportService.cs new file mode 100644 index 000000000..8c440c12d --- /dev/null +++ b/backend/src/Taskdeck.Application/Services/NoteImportService.cs @@ -0,0 +1,332 @@ +using System.Text.RegularExpressions; +using Taskdeck.Application.DTOs; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; + +namespace Taskdeck.Application.Services; + +/// +/// Handles note-style import (markdown files, web clips) by creating +/// capture items through the standard capture pipeline. Imported content +/// never bypasses review — all items enter capture → triage → proposal flow. +/// +public sealed class NoteImportService : INoteImportService +{ + /// Maximum markdown content length (100 KB). + internal const int MaxMarkdownContentLength = 102_400; + + /// Maximum web clip content length (20 KB). + internal const int MaxWebClipContentLength = 20_000; + + /// Maximum filename length. + internal const int MaxFileNameLength = 255; + + /// Maximum URL length. + internal const int MaxUrlLength = 2_048; + + /// Maximum title hint length (from CaptureRequestContract). + internal const int MaxTitleLength = 240; + + /// Maximum number of sections extracted from a single markdown file. + internal const int MaxSectionsPerFile = 50; + + private static readonly Regex HeadingPattern = new( + @"^(#{1,6})\s+(.+)$", + RegexOptions.Multiline | RegexOptions.Compiled); + + private readonly ICaptureService _captureService; + + public NoteImportService(ICaptureService captureService) + { + _captureService = captureService; + } + + public async Task> ImportMarkdownAsync( + Guid userId, + MarkdownImportRequestDto request, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (request == null) + return Result.Failure(ErrorCodes.ValidationError, "Request body is required"); + + if (string.IsNullOrWhiteSpace(request.FileName)) + return Result.Failure(ErrorCodes.ValidationError, "File name is required"); + + if (request.FileName.Length > MaxFileNameLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"File name cannot exceed {MaxFileNameLength} characters"); + + if (!IsValidFileName(request.FileName)) + return Result.Failure( + ErrorCodes.ValidationError, + "File name contains invalid characters"); + + if (string.IsNullOrWhiteSpace(request.Content)) + return Result.Failure(ErrorCodes.ValidationError, "Markdown content is required"); + + if (request.Content.Length > MaxMarkdownContentLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Markdown content cannot exceed {MaxMarkdownContentLength} characters"); + + var sections = SplitMarkdownIntoSections(request.Content); + if (sections.Count == 0) + return Result.Failure(ErrorCodes.ValidationError, "No content sections found in markdown"); + + var warnings = new List(); + var truncatedSectionCount = 0; + if (sections.Count > MaxSectionsPerFile) + { + truncatedSectionCount = sections.Count - MaxSectionsPerFile; + warnings.Add($"Content contained {sections.Count} sections but only the first {MaxSectionsPerFile} were imported. {truncatedSectionCount} section(s) were skipped."); + sections = sections.Take(MaxSectionsPerFile).ToList(); + } + + var items = new List(); + var errors = new List(); + var sectionIndex = 0; + + foreach (var section in sections) + { + var externalRef = $"md://{SanitizeForExternalRef(request.FileName)}"; + if (!string.IsNullOrWhiteSpace(section.Heading)) + { + externalRef += $"#{SanitizeForExternalRef(section.Heading)}"; + } + + var captureText = BuildCaptureText(section); + if (string.IsNullOrWhiteSpace(captureText)) + { + sectionIndex++; + continue; + } + + // Truncate to CaptureRequestContract max if needed + if (captureText.Length > CaptureRequestContract.MaxRawTextLength) + { + captureText = captureText[..CaptureRequestContract.MaxRawTextLength]; + } + + var titleHint = section.Heading; + if (titleHint != null && titleHint.Length > MaxTitleLength) + { + titleHint = titleHint[..MaxTitleLength]; + } + + var truncatedRef = TruncateExternalRef(externalRef); + + var dto = new CreateCaptureItemDto( + request.BoardId, + captureText, + Source: CaptureSource.MarkdownImport.ToString(), + TitleHint: titleHint, + ExternalRef: truncatedRef); + + var result = await _captureService.CreateAsync(userId, dto, cancellationToken); + if (!result.IsSuccess) + { + errors.Add(new NoteImportItemErrorDto( + sectionIndex, + section.Heading, + result.ErrorCode ?? ErrorCodes.UnexpectedError, + result.ErrorMessage ?? "Unknown error")); + sectionIndex++; + continue; + } + + items.Add(new NoteImportItemResultDto( + result.Value.Id, + BuildExcerpt(captureText, 200), + "markdown", + truncatedRef)); + sectionIndex++; + } + + // If all sections failed, return a failure result + if (items.Count == 0 && errors.Count > 0) + { + var lastError = errors[^1]; + return Result.Failure( + lastError.ErrorCode, + $"All {errors.Count} section(s) failed to import. Last error: {lastError.ErrorMessage}"); + } + + return Result.Success(new NoteImportResultDto( + items.Count, + items, + warnings.Count > 0 ? warnings : null, + errors.Count > 0 ? errors : null)); + } + + public async Task> ImportWebClipAsync( + Guid userId, + WebClipImportRequestDto request, + CancellationToken cancellationToken = default) + { + if (userId == Guid.Empty) + return Result.Failure(ErrorCodes.ValidationError, "UserId cannot be empty"); + + if (request == null) + return Result.Failure(ErrorCodes.ValidationError, "Request body is required"); + + if (string.IsNullOrWhiteSpace(request.Url)) + return Result.Failure(ErrorCodes.ValidationError, "URL is required"); + + if (request.Url.Length > MaxUrlLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"URL cannot exceed {MaxUrlLength} characters"); + + if (!IsValidUrl(request.Url)) + return Result.Failure( + ErrorCodes.ValidationError, + "URL must be a valid HTTP or HTTPS URL"); + + if (string.IsNullOrWhiteSpace(request.Content)) + return Result.Failure(ErrorCodes.ValidationError, "Clip content is required"); + + if (request.Content.Length > MaxWebClipContentLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Clip content cannot exceed {MaxWebClipContentLength} characters"); + + if (request.Title != null && request.Title.Length > MaxTitleLength) + return Result.Failure( + ErrorCodes.ValidationError, + $"Title cannot exceed {MaxTitleLength} characters"); + + var captureText = $"[Web Clip] {request.Url}\n\n{request.Content}"; + if (captureText.Length > CaptureRequestContract.MaxRawTextLength) + { + captureText = captureText[..CaptureRequestContract.MaxRawTextLength]; + } + + var externalRef = TruncateExternalRef(request.Url); + + var dto = new CreateCaptureItemDto( + request.BoardId, + captureText, + Source: CaptureSource.WebClip.ToString(), + TitleHint: request.Title, + ExternalRef: externalRef); + + var result = await _captureService.CreateAsync(userId, dto, cancellationToken); + if (!result.IsSuccess) + return Result.Failure(result.ErrorCode, result.ErrorMessage); + + var item = new NoteImportItemResultDto( + result.Value.Id, + BuildExcerpt(captureText, 200), + "webclip", + externalRef); + + return Result.Success(new NoteImportResultDto(1, new List { item }, null, null)); + } + + internal static List SplitMarkdownIntoSections(string content) + { + var sections = new List(); + var lines = content.Split('\n'); + string? currentHeading = null; + var currentBody = new List(); + + foreach (var rawLine in lines) + { + var line = rawLine.TrimEnd('\r'); + var match = HeadingPattern.Match(line); + if (match.Success) + { + // Flush previous section + if (currentHeading != null || currentBody.Count > 0) + { + var bodyText = string.Join("\n", currentBody).Trim(); + if (!string.IsNullOrWhiteSpace(bodyText) || currentHeading != null) + { + sections.Add(new MarkdownSection(currentHeading, bodyText)); + } + } + + currentHeading = match.Groups[2].Value.Trim(); + currentBody.Clear(); + } + else + { + currentBody.Add(line); + } + } + + // Flush final section + if (currentHeading != null || currentBody.Count > 0) + { + var bodyText = string.Join("\n", currentBody).Trim(); + if (!string.IsNullOrWhiteSpace(bodyText) || currentHeading != null) + { + sections.Add(new MarkdownSection(currentHeading, bodyText)); + } + } + + return sections; + } + + private static string BuildCaptureText(MarkdownSection section) + { + if (string.IsNullOrWhiteSpace(section.Heading)) + return section.Body; + + if (string.IsNullOrWhiteSpace(section.Body)) + return section.Heading; + + return $"{section.Heading}\n\n{section.Body}"; + } + + private static bool IsValidFileName(string fileName) + { + // Reject path traversal and dangerous characters + if (fileName.Contains("..") || fileName.Contains('/') || fileName.Contains('\\')) + return false; + + var invalidChars = Path.GetInvalidFileNameChars(); + return !fileName.Any(c => invalidChars.Contains(c)); + } + + private static bool IsValidUrl(string url) + { + if (!Uri.TryCreate(url, UriKind.Absolute, out var uri)) + return false; + + return uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps; + } + + private static string SanitizeForExternalRef(string value) + { + // Replace whitespace with dashes, strip control chars + var sanitized = Regex.Replace(value, @"[\s]+", "-"); + sanitized = Regex.Replace(sanitized, @"[^\w\-\.\(\)]", ""); + return sanitized; + } + + private static string TruncateExternalRef(string value) + { + return value.Length <= CaptureRequestContract.MaxExternalRefLength + ? value + : value[..CaptureRequestContract.MaxExternalRefLength]; + } + + private static string BuildExcerpt(string text, int maxLength) + { + var normalized = string.Join( + " ", + text.Split((char[]?)null, StringSplitOptions.RemoveEmptyEntries)); + + return normalized.Length <= maxLength + ? normalized + : normalized[..maxLength]; + } + + internal sealed record MarkdownSection(string? Heading, string Body); +} diff --git a/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs b/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs index 58feb7c11..298014014 100644 --- a/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs +++ b/backend/src/Taskdeck.Application/Services/RateLimitingSettings.cs @@ -6,6 +6,11 @@ public sealed class RateLimitingSettings public RateLimitPolicySettings AuthPerIp { get; set; } = new(20, 60); public RateLimitPolicySettings HotPathPerUser { get; set; } = new(30, 60); public RateLimitPolicySettings CaptureWritePerUser { get; set; } = new(10, 60); + /// + /// Rate limit for note import endpoints. Lower than CaptureWritePerUser because + /// each import request can create up to 50 capture items. + /// + public RateLimitPolicySettings NoteImportPerUser { get; set; } = new(5, 60); public RateLimitPolicySettings McpPerApiKey { get; set; } = new(60, 60); } diff --git a/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs b/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs index 1231a3f09..d683f17c2 100644 --- a/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs +++ b/backend/src/Taskdeck.Domain/Enums/CaptureSource.cs @@ -11,5 +11,7 @@ public enum CaptureSource Import = 3, Voice = 4, MeetingIntegration = 5, - TranscriptFile = 6 + TranscriptFile = 6, + MarkdownImport = 7, + WebClip = 8 } diff --git a/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs new file mode 100644 index 000000000..76a43a632 --- /dev/null +++ b/backend/tests/Taskdeck.Application.Tests/Services/NoteImportServiceTests.cs @@ -0,0 +1,701 @@ +using FluentAssertions; +using Moq; +using Taskdeck.Application.DTOs; +using Taskdeck.Application.Services; +using Taskdeck.Domain.Common; +using Taskdeck.Domain.Enums; +using Taskdeck.Domain.Exceptions; +using Xunit; + +namespace Taskdeck.Application.Tests.Services; + +public class NoteImportServiceTests +{ + private readonly Mock _captureServiceMock; + private readonly NoteImportService _sut; + + public NoteImportServiceTests() + { + _captureServiceMock = new Mock(); + _sut = new NoteImportService(_captureServiceMock.Object); + } + + private void SetupCaptureServiceReturnsSuccess() + { + var counter = 0; + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => + { + counter++; + var itemId = Guid.NewGuid(); + return Result.Success(new CaptureItemDto( + itemId, + Guid.NewGuid(), + null, + CaptureStatus.New, + CaptureSource.MarkdownImport, + "raw text", + "excerpt", + DateTimeOffset.UtcNow, + null, + 0)); + }); + } + + // --- Markdown import tests --- + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenUserIdIsEmpty() + { + var request = new MarkdownImportRequestDto("test.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.Empty, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenRequestIsNull() + { + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), null!); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameIsEmpty() + { + var request = new MarkdownImportRequestDto("", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("File name"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsPathTraversal() + { + var request = new MarkdownImportRequestDto("../../../etc/passwd", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("invalid characters"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenContentIsEmpty() + { + var request = new MarkdownImportRequestDto("notes.md", ""); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("content is required"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenContentExceedsMaxLength() + { + var request = new MarkdownImportRequestDto( + "notes.md", + new string('x', NoteImportService.MaxMarkdownContentLength + 1)); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot exceed"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldCreateCaptureItems_ForEachSection() + { + SetupCaptureServiceReturnsSuccess(); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two"; + var request = new MarkdownImportRequestDto("notes.md", content); + var userId = Guid.NewGuid(); + + var result = await _sut.ImportMarkdownAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(2); + result.Value.Items.Should().HaveCount(2); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, It.IsAny(), It.IsAny()), + Times.Exactly(2)); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldUseCaptureSourceMarkdownImport() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld"); + var userId = Guid.NewGuid(); + + await _sut.ImportMarkdownAsync(userId, request); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, + It.Is(dto => dto.Source == "MarkdownImport"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldPreserveSourceFileName_InExternalRef() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("my-notes.md", "# Heading\nBody text"); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.ExternalRef != null && dto.ExternalRef.Contains("my-notes.md")), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldSetTitleHint_FromHeading() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# My Important Note\nContent here"); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.TitleHint == "My Important Note"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldHandlePlainTextWithoutHeadings() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "Just some plain text content\nWith multiple lines"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(1); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldPassBoardId_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var boardId = Guid.NewGuid(); + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld", boardId); + + await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.BoardId == boardId), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnItemSourceType_AsMarkdown() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new MarkdownImportRequestDto("notes.md", "# Hello\nWorld"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceType.Should().Be("markdown"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenAllSectionsFail() + { + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(Result.Failure(ErrorCodes.Forbidden, "You do not have access to this board")); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two"; + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorMessage.Should().Contain("failed to import"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnPartialSuccess_WhenSomeSectionsFail() + { + var callCount = 0; + _captureServiceMock + .Setup(s => s.CreateAsync( + It.IsAny(), + It.IsAny(), + It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 2) + { + return Result.Failure(ErrorCodes.Forbidden, "Access denied"); + } + return Result.Success(new CaptureItemDto( + Guid.NewGuid(), + Guid.NewGuid(), + null, + CaptureStatus.New, + CaptureSource.MarkdownImport, + "raw text", + "excerpt", + DateTimeOffset.UtcNow, + null, + 0)); + }); + + var content = "# Section One\nBody of section one\n\n# Section Two\nBody of section two\n\n# Section Three\nBody three"; + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(2); + result.Value.Errors.Should().NotBeNull(); + result.Value.Errors.Should().HaveCount(1); + result.Value.Errors![0].SectionIndex.Should().Be(1); + result.Value.Errors![0].Heading.Should().Be("Section Two"); + result.Value.Errors![0].ErrorCode.Should().Be(ErrorCodes.Forbidden); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnWarning_WhenSectionsTruncated() + { + SetupCaptureServiceReturnsSuccess(); + + // Create content with more than MaxSectionsPerFile sections + var sections = Enumerable.Range(1, NoteImportService.MaxSectionsPerFile + 5) + .Select(i => $"# Section {i}\nBody {i}") + .ToList(); + var content = string.Join("\n\n", sections); + + var request = new MarkdownImportRequestDto("notes.md", content); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(NoteImportService.MaxSectionsPerFile); + result.Value.Warnings.Should().NotBeNull(); + result.Value.Warnings.Should().HaveCount(1); + result.Value.Warnings![0].Should().Contain("5 section(s) were skipped"); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldReturnTruncatedExternalRef_InResponseItems() + { + SetupCaptureServiceReturnsSuccess(); + + // Use a heading long enough that after md:// prefix the ref would be very long + var request = new MarkdownImportRequestDto("notes.md", "# Short Heading\nBody text"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + // The sourceRef in the response should not exceed MaxExternalRefLength + foreach (var item in result.Value.Items) + { + item.SourceRef.Should().NotBeNull(); + item.SourceRef!.Length.Should().BeLessOrEqualTo(CaptureRequestContract.MaxExternalRefLength); + } + } + + // --- Web clip import tests --- + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUserIdIsEmpty() + { + var request = new WebClipImportRequestDto("https://example.com", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.Empty, request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenRequestIsNull() + { + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), null!); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsEmpty() + { + var request = new WebClipImportRequestDto("", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("URL is required"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsNotHttpOrHttps() + { + var request = new WebClipImportRequestDto("ftp://evil.com/file", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("valid HTTP or HTTPS URL"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlIsInvalid() + { + var request = new WebClipImportRequestDto("not a url", "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenContentIsEmpty() + { + var request = new WebClipImportRequestDto("https://example.com", ""); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("content is required"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenContentExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com", + new string('x', NoteImportService.MaxWebClipContentLength + 1)); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + result.ErrorMessage.Should().Contain("cannot exceed"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldCreateSingleCaptureItem() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/article", + "Important content from article"); + var userId = Guid.NewGuid(); + + var result = await _sut.ImportWebClipAsync(userId, request); + + result.IsSuccess.Should().BeTrue(); + result.Value.ItemsCreated.Should().Be(1); + result.Value.Items.Should().HaveCount(1); + + _captureServiceMock.Verify( + s => s.CreateAsync(userId, It.IsAny(), It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldUseCaptureSourceWebClip() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.Source == "WebClip"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldPreserveUrl_InExternalRef() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/important", + "content"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.ExternalRef == "https://example.com/important"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldIncludeUrlInCaptureText() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com/article", + "Content from the article"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => + dto.Text.Contains("https://example.com/article") && + dto.Text.Contains("Content from the article")), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldSetTitleHint_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + "Article Title"); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.TitleHint == "Article Title"), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldPassBoardId_WhenProvided() + { + SetupCaptureServiceReturnsSuccess(); + + var boardId = Guid.NewGuid(); + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + null, + boardId); + + await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + _captureServiceMock.Verify( + s => s.CreateAsync(It.IsAny(), + It.Is(dto => dto.BoardId == boardId), + It.IsAny()), + Times.Once); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldReturnItemSourceType_AsWebClip() + { + SetupCaptureServiceReturnsSuccess(); + + var request = new WebClipImportRequestDto( + "https://example.com", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceType.Should().Be("webclip"); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenUrlExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com/" + new string('a', NoteImportService.MaxUrlLength), + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldFail_WhenTitleExceedsMaxLength() + { + var request = new WebClipImportRequestDto( + "https://example.com", + "content", + new string('t', NoteImportService.MaxTitleLength + 1)); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + // --- Markdown section splitting tests --- + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleSingleSection() + { + var sections = NoteImportService.SplitMarkdownIntoSections("# Title\nBody text"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().Be("Title"); + sections[0].Body.Should().Be("Body text"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleMultipleSections() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "# First\nBody one\n\n# Second\nBody two\n\n## Subsection\nBody three"); + + sections.Should().HaveCount(3); + sections[0].Heading.Should().Be("First"); + sections[1].Heading.Should().Be("Second"); + sections[2].Heading.Should().Be("Subsection"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleContentBeforeFirstHeading() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "Preamble text\n\n# First Heading\nBody"); + + sections.Should().HaveCount(2); + sections[0].Heading.Should().BeNull(); + sections[0].Body.Should().Be("Preamble text"); + sections[1].Heading.Should().Be("First Heading"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandlePlainTextWithNoHeadings() + { + var sections = NoteImportService.SplitMarkdownIntoSections( + "Just some plain text\nWith multiple lines"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().BeNull(); + sections[0].Body.Should().Contain("Just some plain text"); + } + + [Fact] + public void SplitMarkdownIntoSections_ShouldHandleEmptyBodyAfterHeading() + { + var sections = NoteImportService.SplitMarkdownIntoSections("# Empty Section"); + + sections.Should().HaveCount(1); + sections[0].Heading.Should().Be("Empty Section"); + sections[0].Body.Should().BeEmpty(); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsBackslash() + { + var request = new MarkdownImportRequestDto("..\\..\\secret.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportMarkdownAsync_ShouldFail_WhenFileNameContainsForwardSlash() + { + var request = new MarkdownImportRequestDto("path/to/file.md", "# Hello"); + + var result = await _sut.ImportMarkdownAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldRejectJavascriptUrl() + { + var request = new WebClipImportRequestDto( + "javascript:alert(1)", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldRejectDataUrl() + { + var request = new WebClipImportRequestDto( + "data:text/html,", + "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeFalse(); + result.ErrorCode.Should().Be(ErrorCodes.ValidationError); + } + + [Fact] + public async Task ImportWebClipAsync_ShouldReturnTruncatedRef_WhenUrlExceedsLimit() + { + SetupCaptureServiceReturnsSuccess(); + + // Create a URL that is longer than MaxExternalRefLength + var longPath = new string('a', CaptureRequestContract.MaxExternalRefLength); + var longUrl = $"https://example.com/{longPath}"; + var request = new WebClipImportRequestDto(longUrl[..NoteImportService.MaxUrlLength], "content"); + + var result = await _sut.ImportWebClipAsync(Guid.NewGuid(), request); + + result.IsSuccess.Should().BeTrue(); + result.Value.Items[0].SourceRef.Should().NotBeNull(); + result.Value.Items[0].SourceRef!.Length.Should().BeLessOrEqualTo(CaptureRequestContract.MaxExternalRefLength); + } +} diff --git a/frontend/taskdeck-web/src/api/noteImportApi.ts b/frontend/taskdeck-web/src/api/noteImportApi.ts new file mode 100644 index 000000000..0f9a8d20a --- /dev/null +++ b/frontend/taskdeck-web/src/api/noteImportApi.ts @@ -0,0 +1,18 @@ +import http from './http' +import type { + MarkdownImportRequest, + WebClipImportRequest, + NoteImportResult, +} from '../types/note-import' + +export const noteImportApi = { + async importMarkdown(request: MarkdownImportRequest): Promise { + const { data } = await http.post('/import/notes/markdown', request) + return data + }, + + async importWebClip(request: WebClipImportRequest): Promise { + const { data } = await http.post('/import/notes/webclip', request) + return data + }, +} diff --git a/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts b/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts new file mode 100644 index 000000000..3183e4a6e --- /dev/null +++ b/frontend/taskdeck-web/src/tests/api/noteImportApi.spec.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import http from '../../api/http' +import { noteImportApi } from '../../api/noteImportApi' + +vi.mock('../../api/http', () => ({ + default: { + get: vi.fn(), + post: vi.fn(), + put: vi.fn(), + }, +})) + +describe('noteImportApi', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + describe('importMarkdown', () => { + it('posts markdown import request', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { + itemsCreated: 2, + items: [ + { + captureItemId: 'item-1', + textExcerpt: 'Section one content', + sourceType: 'markdown', + sourceRef: 'md://notes.md#Section-One', + }, + { + captureItemId: 'item-2', + textExcerpt: 'Section two content', + sourceType: 'markdown', + sourceRef: 'md://notes.md#Section-Two', + }, + ], + }, + }) + + const result = await noteImportApi.importMarkdown({ + fileName: 'notes.md', + content: '# Section One\nContent\n\n# Section Two\nMore content', + boardId: 'board-123', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/markdown', { + fileName: 'notes.md', + content: '# Section One\nContent\n\n# Section Two\nMore content', + boardId: 'board-123', + }) + expect(result.itemsCreated).toBe(2) + expect(result.items).toHaveLength(2) + expect(result.items[0].sourceType).toBe('markdown') + }) + + it('posts markdown import without boardId', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { itemsCreated: 1, items: [] }, + }) + + await noteImportApi.importMarkdown({ + fileName: 'notes.md', + content: '# Hello', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/markdown', { + fileName: 'notes.md', + content: '# Hello', + }) + }) + }) + + describe('importWebClip', () => { + it('posts web clip import request', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { + itemsCreated: 1, + items: [ + { + captureItemId: 'clip-1', + textExcerpt: '[Web Clip] https://example.com', + sourceType: 'webclip', + sourceRef: 'https://example.com', + }, + ], + }, + }) + + const result = await noteImportApi.importWebClip({ + url: 'https://example.com', + content: 'Important article content', + title: 'Article Title', + boardId: null, + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/webclip', { + url: 'https://example.com', + content: 'Important article content', + title: 'Article Title', + boardId: null, + }) + expect(result.itemsCreated).toBe(1) + expect(result.items[0].sourceType).toBe('webclip') + }) + + it('posts web clip without optional fields', async () => { + vi.mocked(http.post).mockResolvedValue({ + data: { itemsCreated: 1, items: [] }, + }) + + await noteImportApi.importWebClip({ + url: 'https://example.com', + content: 'Clip content', + }) + + expect(http.post).toHaveBeenCalledWith('/import/notes/webclip', { + url: 'https://example.com', + content: 'Clip content', + }) + }) + }) +}) diff --git a/frontend/taskdeck-web/src/types/capture.ts b/frontend/taskdeck-web/src/types/capture.ts index 4276d22a0..ad03d1523 100644 --- a/frontend/taskdeck-web/src/types/capture.ts +++ b/frontend/taskdeck-web/src/types/capture.ts @@ -34,6 +34,8 @@ export type CaptureSource = | 'Voice' | 'MeetingIntegration' | 'TranscriptFile' + | 'MarkdownImport' + | 'WebClip' export type CaptureSourceValue = CaptureSource | number diff --git a/frontend/taskdeck-web/src/types/note-import.ts b/frontend/taskdeck-web/src/types/note-import.ts new file mode 100644 index 000000000..99332f335 --- /dev/null +++ b/frontend/taskdeck-web/src/types/note-import.ts @@ -0,0 +1,24 @@ +export interface MarkdownImportRequest { + fileName: string + content: string + boardId?: string | null +} + +export interface WebClipImportRequest { + url: string + content: string + title?: string | null + boardId?: string | null +} + +export interface NoteImportItemResult { + captureItemId: string + textExcerpt: string + sourceType: string + sourceRef: string | null +} + +export interface NoteImportResult { + itemsCreated: number + items: NoteImportItemResult[] +} diff --git a/frontend/taskdeck-web/src/views/ExportImportView.vue b/frontend/taskdeck-web/src/views/ExportImportView.vue index 105077796..06d512db1 100644 --- a/frontend/taskdeck-web/src/views/ExportImportView.vue +++ b/frontend/taskdeck-web/src/views/ExportImportView.vue @@ -1,24 +1,46 @@ @@ -188,7 +466,7 @@ function resetImport() { .td-section-title { font-size: var(--td-font-lg); font-weight: 600; margin-bottom: var(--td-space-2); color: var(--td-text-primary); } .td-section-desc { font-size: var(--td-font-sm); color: var(--td-text-secondary); margin-bottom: var(--td-space-4); } .td-export-form { display: flex; gap: var(--td-space-3); align-items: flex-end; margin-bottom: var(--td-space-4); } -.td-form-group { display: flex; flex-direction: column; gap: var(--td-space-1); flex: 1; } +.td-form-group { display: flex; flex-direction: column; gap: var(--td-space-1); flex: 1; margin-bottom: var(--td-space-3); } .td-label { font-size: var(--td-font-sm); font-weight: 500; color: var(--td-text-secondary); } .td-input { padding: var(--td-space-2) var(--td-space-3); border: 1px solid var(--td-border-default); border-radius: var(--td-radius-md); font-size: var(--td-font-sm); } .td-input:focus { outline: none; border-color: var(--td-border-focus); box-shadow: var(--td-focus-ring); } @@ -211,4 +489,9 @@ function resetImport() { .td-import-result--success { background: var(--td-color-success-light); color: var(--td-color-success); } .td-import-result--error { background: var(--td-color-error-light); color: var(--td-color-error); } .td-import-summary { margin-bottom: var(--td-space-4); color: var(--td-text-secondary); font-size: var(--td-font-sm); } +.td-note-import-items { display: flex; flex-direction: column; gap: var(--td-space-2); margin-bottom: var(--td-space-4); } +.td-note-import-item { display: flex; align-items: center; gap: var(--td-space-2); padding: var(--td-space-2) var(--td-space-3); background: var(--td-surface-container-lowest); border-radius: var(--td-radius-md); font-size: var(--td-font-sm); } +.td-note-import-badge { display: inline-block; padding: var(--td-space-0) var(--td-space-2); background: var(--td-color-primary); color: var(--td-text-inverse); border-radius: var(--td-radius-sm); font-size: var(--td-font-xs); font-weight: 600; text-transform: uppercase; flex-shrink: 0; } +.td-note-import-excerpt { color: var(--td-text-primary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.td-note-import-ref { color: var(--td-text-tertiary); font-size: var(--td-font-xs); flex-shrink: 0; max-width: 200px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }