Merge branch 'main' of https://git.azuze.fr/kawa/SharepointToolbox-Web
This commit is contained in:
@@ -7,6 +7,7 @@
|
|||||||
@inject NavigationManager Nav
|
@inject NavigationManager Nav
|
||||||
@inject IJSRuntime JS
|
@inject IJSRuntime JS
|
||||||
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
@inject SharepointToolbox.Web.Services.OAuth.IOAuthFlowCache OAuthCache
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||||||
@using Microsoft.AspNetCore.Components.Authorization
|
@using Microsoft.AspNetCore.Components.Authorization
|
||||||
@using Microsoft.AspNetCore.WebUtilities
|
@using Microsoft.AspNetCore.WebUtilities
|
||||||
@using Microsoft.JSInterop
|
@using Microsoft.JSInterop
|
||||||
@@ -112,6 +113,9 @@
|
|||||||
<main class="content">
|
<main class="content">
|
||||||
@if (UserContext.IsAuthenticated)
|
@if (UserContext.IsAuthenticated)
|
||||||
{
|
{
|
||||||
|
<header class="topbar">
|
||||||
|
<ProfileSelector />
|
||||||
|
</header>
|
||||||
@Body
|
@Body
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -222,9 +226,20 @@
|
|||||||
{
|
{
|
||||||
await CredStore.SetAsync(tokens);
|
await CredStore.SetAsync(tokens);
|
||||||
await SessionManager.ClearAllAsync();
|
await SessionManager.ClearAllAsync();
|
||||||
|
|
||||||
|
// Re-select the profile that started this connect flow. The forceLoad redirect tore
|
||||||
|
// down the previous circuit, so the in-memory selection in UserSessionService (scoped)
|
||||||
|
// was lost. Restoring it here means a successful auth lands the user on a fully
|
||||||
|
// selected profile instead of forcing a second "Select" click.
|
||||||
|
if (query.TryGetValue("profile_id", out var profileId) && !string.IsNullOrEmpty(profileId))
|
||||||
|
{
|
||||||
|
var profile = (await ProfileRepo.LoadAsync()).FirstOrDefault(p => p.Id == profileId);
|
||||||
|
if (profile is not null)
|
||||||
|
Session.SetProfile(profile);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Strip token_key from URL bar
|
// Strip token_key / profile_id from URL bar
|
||||||
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
Nav.NavigateTo(uri.GetLeftPart(UriPartial.Path), replace: true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,10 +24,7 @@
|
|||||||
<option value="Folders">Folders</option>
|
<option value="Folders">Folders</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
|
||||||
<label class="form-label">Library (optional)</label>
|
|
||||||
<input class="form-input" @bind="_library" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-row">
|
<div class="flex-row">
|
||||||
<label><input type="checkbox" @bind="_matchSize" /> Match size</label>
|
<label><input type="checkbox" @bind="_matchSize" /> Match size</label>
|
||||||
|
|||||||
@@ -16,10 +16,7 @@
|
|||||||
<div class="card-title">Source</div>
|
<div class="card-title">Source</div>
|
||||||
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
|
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_srcSites" Single="true" />
|
||||||
<div class="form-row mt-8">
|
<div class="form-row mt-8">
|
||||||
<div class="form-group">
|
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_srcSites.FirstOrDefault()?.Url)" @bind-Library="_srcLibrary" Label="Source Library" />
|
||||||
<label class="form-label">Source Library</label>
|
|
||||||
<input class="form-input" @bind="_srcLibrary" placeholder="Shared Documents" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Source Folder (optional)</label>
|
<label class="form-label">Source Folder (optional)</label>
|
||||||
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
|
<input class="form-input" @bind="_srcFolder" placeholder="SubFolder/Path" />
|
||||||
@@ -31,10 +28,7 @@
|
|||||||
<div class="card-title">Destination</div>
|
<div class="card-title">Destination</div>
|
||||||
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
|
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_dstSites" Single="true" />
|
||||||
<div class="form-row mt-8">
|
<div class="form-row mt-8">
|
||||||
<div class="form-group">
|
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_dstSites.FirstOrDefault()?.Url)" @bind-Library="_dstLibrary" Label="Destination Library" />
|
||||||
<label class="form-label">Destination Library</label>
|
|
||||||
<input class="form-input" @bind="_dstLibrary" placeholder="Shared Documents" />
|
|
||||||
</div>
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<label class="form-label">Destination Folder (optional)</label>
|
<label class="form-label">Destination Folder (optional)</label>
|
||||||
<input class="form-input" @bind="_dstFolder" />
|
<input class="form-input" @bind="_dstFolder" />
|
||||||
|
|||||||
@@ -17,15 +17,43 @@
|
|||||||
<div class="card">
|
<div class="card">
|
||||||
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
|
<SitePicker Profile="Session.CurrentProfile!" @bind-SelectedSites="_sites" Single="true" />
|
||||||
<div class="form-row mt-8">
|
<div class="form-row mt-8">
|
||||||
<div class="form-group">
|
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_libraryTitle" Label="Library Title" />
|
||||||
<label class="form-label">Library Title</label>
|
</div>
|
||||||
<input class="form-input" @bind="_libraryTitle" placeholder="Shared Documents" />
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Source</label>
|
||||||
|
<div class="flex-row">
|
||||||
|
<button class="btn btn-sm @(_mode == InputMode.Csv ? "btn-primary" : "btn-secondary")"
|
||||||
|
type="button" @onclick="() => SetMode(InputMode.Csv)">Upload CSV</button>
|
||||||
|
<button class="btn btn-sm @(_mode == InputMode.Builder ? "btn-primary" : "btn-secondary")"
|
||||||
|
type="button" @onclick="() => SetMode(InputMode.Builder)">Build visually</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
|
@if (_mode == InputMode.Csv)
|
||||||
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
|
{
|
||||||
</div>
|
<div class="form-group">
|
||||||
|
<label class="form-label">CSV File (Level1, Level2, Level3, Level4)</label>
|
||||||
|
<InputFile OnChange="LoadFile" accept=".csv" class="form-input" />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">Folder Structure</label>
|
||||||
|
<div class="folder-builder">
|
||||||
|
@foreach (var root in _tree)
|
||||||
|
{
|
||||||
|
<FolderTreeNode Node="root" Depth="1" OnRemove="RemoveRoot" OnChanged="RebuildFromTree" />
|
||||||
|
}
|
||||||
|
@if (_tree.Count == 0)
|
||||||
|
{
|
||||||
|
<p class="text-muted">No folders yet. Add a top-level folder to start.</p>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-sm mt-8" type="button" @onclick="AddRoot">+ Add top-level folder</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
@if (_rows.Count > 0)
|
@if (_rows.Count > 0)
|
||||||
{
|
{
|
||||||
@@ -53,14 +81,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@code {
|
@code {
|
||||||
|
private enum InputMode { Csv, Builder }
|
||||||
|
private InputMode _mode = InputMode.Csv;
|
||||||
|
|
||||||
private List<SiteInfo> _sites = new();
|
private List<SiteInfo> _sites = new();
|
||||||
private string _libraryTitle = string.Empty;
|
private string _libraryTitle = string.Empty;
|
||||||
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
|
private List<CsvValidationRow<FolderStructureRow>> _rows = new();
|
||||||
|
private readonly List<FolderNode> _tree = new();
|
||||||
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
private bool _running; private string _status = string.Empty, _error = string.Empty;
|
||||||
private int _current, _total;
|
private int _current, _total;
|
||||||
private BulkOperationSummary<string>? _summary;
|
private BulkOperationSummary<string>? _summary;
|
||||||
private CancellationTokenSource? _cts;
|
private CancellationTokenSource? _cts;
|
||||||
|
|
||||||
|
private void SetMode(InputMode mode)
|
||||||
|
{
|
||||||
|
_mode = mode;
|
||||||
|
_rows.Clear();
|
||||||
|
_summary = null; _error = string.Empty;
|
||||||
|
if (mode == InputMode.Builder) RebuildFromTree();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task LoadFile(InputFileChangeEventArgs e)
|
private async Task LoadFile(InputFileChangeEventArgs e)
|
||||||
{
|
{
|
||||||
_rows.Clear();
|
_rows.Clear();
|
||||||
@@ -68,6 +108,26 @@
|
|||||||
_rows = CsvValidation.ParseAndValidateFolders(stream);
|
_rows = CsvValidation.ParseAndValidateFolders(stream);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void AddRoot()
|
||||||
|
{
|
||||||
|
_tree.Add(new FolderNode());
|
||||||
|
RebuildFromTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void RemoveRoot(FolderNode node)
|
||||||
|
{
|
||||||
|
_tree.Remove(node);
|
||||||
|
RebuildFromTree();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert the visual tree into validation rows so the count + Create button share the CSV path.
|
||||||
|
private void RebuildFromTree()
|
||||||
|
{
|
||||||
|
_rows = FolderNode.Flatten(_tree)
|
||||||
|
.Select(r => new CsvValidationRow<FolderStructureRow>(r, new List<string>()))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
private async Task RunCreate()
|
private async Task RunCreate()
|
||||||
{
|
{
|
||||||
_error = string.Empty; _summary = null; _running = true;
|
_error = string.Empty; _summary = null; _running = true;
|
||||||
|
|||||||
@@ -42,10 +42,7 @@
|
|||||||
<label class="form-label">Modified by</label>
|
<label class="form-label">Modified by</label>
|
||||||
<input class="form-input" @bind="_modifiedBy" />
|
<input class="form-input" @bind="_modifiedBy" />
|
||||||
</div>
|
</div>
|
||||||
<div class="form-group">
|
<LibraryPicker Profile="Session.CurrentProfile!" SiteUrl="@(_sites.FirstOrDefault()?.Url)" @bind-Library="_library" Label="Library (optional)" Placeholder="" />
|
||||||
<label class="form-label">Library (optional)</label>
|
|
||||||
<input class="form-input" @bind="_library" />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="flex-row mt-8">
|
<div class="flex-row mt-8">
|
||||||
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
|
<button class="btn btn-primary" @onclick="RunSearch" disabled="@_running">
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
@* Recursive editor row for one folder in the visual builder. *@
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
|
||||||
|
<div class="folder-node" style="margin-left:@(Depth > 1 ? "18px" : "0")">
|
||||||
|
<div class="flex-row" style="gap:6px">
|
||||||
|
<span class="text-muted" style="font-family:monospace">📁</span>
|
||||||
|
<input class="form-input" style="width:auto;flex:1;min-width:160px"
|
||||||
|
placeholder="Folder name" value="@Node.Name"
|
||||||
|
@oninput="OnNameInput" />
|
||||||
|
@if (Depth < FolderNode.MaxDepth)
|
||||||
|
{
|
||||||
|
<button class="btn btn-secondary btn-sm" type="button" @onclick="AddChild" title="Add subfolder">+ Sub</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-danger btn-sm" type="button" @onclick="() => OnRemove.InvokeAsync(Node)" title="Remove">✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@foreach (var child in Node.Children)
|
||||||
|
{
|
||||||
|
<FolderTreeNode Node="child" Depth="Depth + 1" OnRemove="RemoveChild" OnChanged="OnChanged" />
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public FolderNode Node { get; set; } = default!;
|
||||||
|
[Parameter] public int Depth { get; set; } = 1;
|
||||||
|
[Parameter] public EventCallback<FolderNode> OnRemove { get; set; }
|
||||||
|
[Parameter] public EventCallback OnChanged { get; set; }
|
||||||
|
|
||||||
|
private async Task OnNameInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
Node.Name = e.Value?.ToString() ?? string.Empty;
|
||||||
|
await OnChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task AddChild()
|
||||||
|
{
|
||||||
|
Node.Children.Add(new FolderNode());
|
||||||
|
await OnChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task RemoveChild(FolderNode child)
|
||||||
|
{
|
||||||
|
Node.Children.Remove(child);
|
||||||
|
await OnChanged.InvokeAsync();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
@inject ILibraryDiscoveryService LibraryDiscovery
|
||||||
|
|
||||||
|
@* Library name field with a picker: type a title, or click Browse to load and
|
||||||
|
choose from the libraries on the selected site. *@
|
||||||
|
<div class="form-group library-picker">
|
||||||
|
<label class="form-label">@Label</label>
|
||||||
|
<div class="flex-row" style="gap:8px;align-items:stretch">
|
||||||
|
<input class="form-input" style="flex:1"
|
||||||
|
placeholder="@Placeholder"
|
||||||
|
value="@Library"
|
||||||
|
@oninput="OnTextInput"
|
||||||
|
disabled="@Disabled" />
|
||||||
|
<button type="button" class="btn btn-secondary"
|
||||||
|
@onclick="Browse"
|
||||||
|
disabled="@(Disabled || _loading)">
|
||||||
|
@(_loading ? "Loading…" : "Browse")
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (!string.IsNullOrEmpty(_error))
|
||||||
|
{
|
||||||
|
<div class="alert alert-error mt-8">@_error</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (_open)
|
||||||
|
{
|
||||||
|
<div class="library-picker-list" style="max-height:200px;overflow:auto;border:1px solid var(--border);border-radius:4px;padding:4px;margin-top:6px">
|
||||||
|
@foreach (var lib in _libraries)
|
||||||
|
{
|
||||||
|
<button type="button"
|
||||||
|
class="library-picker-item @(string.Equals(lib, Library, StringComparison.OrdinalIgnoreCase) ? "active" : "")"
|
||||||
|
style="display:block;width:100%;text-align:left;padding:4px 8px;border:none;background:none;cursor:pointer;border-radius:3px"
|
||||||
|
@onclick="() => Pick(lib)">
|
||||||
|
@lib
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if (_libraries.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="text-muted" style="padding:6px">No document libraries found on this site.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
[Parameter, EditorRequired] public TenantProfile Profile { get; set; } = default!;
|
||||||
|
[Parameter] public string? SiteUrl { get; set; }
|
||||||
|
[Parameter] public string Library { get; set; } = string.Empty;
|
||||||
|
[Parameter] public EventCallback<string> LibraryChanged { get; set; }
|
||||||
|
[Parameter] public string Label { get; set; } = "Library";
|
||||||
|
[Parameter] public string Placeholder { get; set; } = "Shared Documents";
|
||||||
|
[Parameter] public bool Disabled { get; set; }
|
||||||
|
|
||||||
|
private List<string> _libraries = new();
|
||||||
|
private bool _loading, _open;
|
||||||
|
private string _error = string.Empty;
|
||||||
|
private string? _loadedForSite;
|
||||||
|
|
||||||
|
private async Task OnTextInput(ChangeEventArgs e)
|
||||||
|
{
|
||||||
|
Library = e.Value?.ToString() ?? string.Empty;
|
||||||
|
await LibraryChanged.InvokeAsync(Library);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Browse()
|
||||||
|
{
|
||||||
|
_error = string.Empty;
|
||||||
|
if (string.IsNullOrWhiteSpace(SiteUrl)) { _error = "Select a site first."; return; }
|
||||||
|
|
||||||
|
// Toggle closed if already showing the list for this site.
|
||||||
|
if (_open && _loadedForSite == SiteUrl) { _open = false; return; }
|
||||||
|
|
||||||
|
// Reload when the site changed since the last load.
|
||||||
|
if (_libraries.Count == 0 || _loadedForSite != SiteUrl)
|
||||||
|
{
|
||||||
|
_loading = true;
|
||||||
|
try
|
||||||
|
{
|
||||||
|
_libraries = (await LibraryDiscovery.ListLibrariesAsync(Profile, SiteUrl!)).ToList();
|
||||||
|
_loadedForSite = SiteUrl;
|
||||||
|
}
|
||||||
|
catch (Exception ex) { _error = ex.Message; return; }
|
||||||
|
finally { _loading = false; }
|
||||||
|
}
|
||||||
|
_open = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task Pick(string lib)
|
||||||
|
{
|
||||||
|
Library = lib;
|
||||||
|
_open = false;
|
||||||
|
await LibraryChanged.InvokeAsync(Library);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
@inject IUserSessionService Session
|
||||||
|
@inject SharepointToolbox.Web.Infrastructure.Persistence.ProfileRepository ProfileRepo
|
||||||
|
@implements IDisposable
|
||||||
|
@using SharepointToolbox.Web.Core.Models
|
||||||
|
@using SharepointToolbox.Web.Services.Session
|
||||||
|
|
||||||
|
<div class="profile-selector">
|
||||||
|
<button class="profile-selector-trigger @(Session.HasProfile ? "" : "unset")" @onclick="ToggleAsync">
|
||||||
|
<span class="profile-selector-icon">🏢</span>
|
||||||
|
<span class="profile-selector-name">@(Session.CurrentProfile?.Name ?? "Select a profile")</span>
|
||||||
|
<span class="profile-selector-caret @(_open ? "open" : "")">▾</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
@if (_open)
|
||||||
|
{
|
||||||
|
<div class="profile-selector-backdrop" @onclick="Close"></div>
|
||||||
|
<div class="profile-selector-menu">
|
||||||
|
@if (_profiles.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="profile-selector-empty">No profiles configured.</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var p in _profiles)
|
||||||
|
{
|
||||||
|
var active = Session.CurrentProfile?.Id == p.Id;
|
||||||
|
<button class="profile-selector-item @(active ? "active" : "")" @onclick="() => Select(p)">
|
||||||
|
<span class="profile-selector-item-text">
|
||||||
|
<span class="profile-selector-item-name">@p.Name</span>
|
||||||
|
<span class="profile-selector-item-url">@p.TenantUrl</span>
|
||||||
|
</span>
|
||||||
|
@if (active)
|
||||||
|
{
|
||||||
|
<span class="profile-selector-check">✓</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<a class="profile-selector-manage" href="/profiles" @onclick="Close">⚙️ Manage profiles</a>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@code {
|
||||||
|
private bool _open;
|
||||||
|
private List<TenantProfile> _profiles = new();
|
||||||
|
|
||||||
|
protected override async Task OnInitializedAsync()
|
||||||
|
{
|
||||||
|
Session.ProfileChanged += OnProfileChanged;
|
||||||
|
await LoadProfilesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnProfileChanged() => InvokeAsync(StateHasChanged);
|
||||||
|
|
||||||
|
private async Task LoadProfilesAsync()
|
||||||
|
=> _profiles = (await ProfileRepo.LoadAsync()).ToList();
|
||||||
|
|
||||||
|
private async Task ToggleAsync()
|
||||||
|
{
|
||||||
|
_open = !_open;
|
||||||
|
// Refresh the list when opening so newly created/edited profiles show up without a reload.
|
||||||
|
if (_open) await LoadProfilesAsync();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Close() => _open = false;
|
||||||
|
|
||||||
|
private void Select(TenantProfile p)
|
||||||
|
{
|
||||||
|
_open = false;
|
||||||
|
Session.SetProfile(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
public void Dispose() => Session.ProfileChanged -= OnProfileChanged;
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
namespace SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Editable node for the visual folder-structure builder. SharePoint folders
|
||||||
|
/// are limited to 4 nesting levels here, matching the CSV template (Level1..Level4).
|
||||||
|
/// </summary>
|
||||||
|
public class FolderNode
|
||||||
|
{
|
||||||
|
public const int MaxDepth = 4;
|
||||||
|
|
||||||
|
public string Name { get; set; } = string.Empty;
|
||||||
|
public List<FolderNode> Children { get; } = new();
|
||||||
|
|
||||||
|
/// <summary>Flatten the tree into one <see cref="FolderStructureRow"/> per leaf path.</summary>
|
||||||
|
public static List<FolderStructureRow> Flatten(IEnumerable<FolderNode> roots)
|
||||||
|
{
|
||||||
|
var rows = new List<FolderStructureRow>();
|
||||||
|
foreach (var root in roots)
|
||||||
|
Walk(root, new List<string>(), rows);
|
||||||
|
return rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void Walk(FolderNode node, List<string> ancestors, List<FolderStructureRow> rows)
|
||||||
|
{
|
||||||
|
var name = node.Name.Trim();
|
||||||
|
if (string.IsNullOrEmpty(name)) return;
|
||||||
|
|
||||||
|
var path = new List<string>(ancestors) { name };
|
||||||
|
|
||||||
|
// Only emit a row for leaves; intermediate folders are created as ancestors of the leaf path.
|
||||||
|
if (node.Children.Count == 0)
|
||||||
|
{
|
||||||
|
rows.Add(new FolderStructureRow
|
||||||
|
{
|
||||||
|
Level1 = path.ElementAtOrDefault(0) ?? string.Empty,
|
||||||
|
Level2 = path.ElementAtOrDefault(1) ?? string.Empty,
|
||||||
|
Level3 = path.ElementAtOrDefault(2) ?? string.Empty,
|
||||||
|
Level4 = path.ElementAtOrDefault(3) ?? string.Empty,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (path.Count >= MaxDepth) return; // can't nest deeper
|
||||||
|
foreach (var child in node.Children)
|
||||||
|
Walk(child, path, rows);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -124,7 +124,14 @@ public static class OAuthEndpoints
|
|||||||
var tokenKey = Guid.NewGuid().ToString("N");
|
var tokenKey = Guid.NewGuid().ToString("N");
|
||||||
flowCache.StoreTokens(tokenKey, tokens);
|
flowCache.StoreTokens(tokenKey, tokens);
|
||||||
|
|
||||||
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, "token_key", tokenKey);
|
// Pass the profile id back too: the connect flow did a full HTTP redirect that tore
|
||||||
|
// down the Blazor circuit, dropping the in-memory profile selection. The layout
|
||||||
|
// re-selects it from this param so the user lands fully connected — no second click.
|
||||||
|
var returnTo = QueryHelpers.AddQueryString(flowState.ReturnUrl, new Dictionary<string, string?>
|
||||||
|
{
|
||||||
|
["token_key"] = tokenKey,
|
||||||
|
["profile_id"] = flowState.ProfileId,
|
||||||
|
});
|
||||||
return Results.Redirect(returnTo);
|
return Results.Redirect(returnTo);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ builder.Services.AddScoped<IVersionCleanupService, VersionCleanupService>();
|
|||||||
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
|
builder.Services.AddScoped<IUserAccessAuditService, UserAccessAuditService>();
|
||||||
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
builder.Services.AddScoped<IGraphUserDirectoryService, GraphUserDirectoryService>();
|
||||||
builder.Services.AddScoped<ISiteDiscoveryService, SiteDiscoveryService>();
|
builder.Services.AddScoped<ISiteDiscoveryService, SiteDiscoveryService>();
|
||||||
|
builder.Services.AddScoped<ILibraryDiscoveryService, LibraryDiscoveryService>();
|
||||||
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
|
builder.Services.AddScoped<IFolderStructureService, FolderStructureService>();
|
||||||
builder.Services.AddScoped<ITemplateService, TemplateService>();
|
builder.Services.AddScoped<ITemplateService, TemplateService>();
|
||||||
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
builder.Services.AddScoped<ICsvValidationService, CsvValidationService>();
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Lists the document libraries of a single SharePoint site so users can pick a
|
||||||
|
/// library from a dropdown instead of typing its title by hand.
|
||||||
|
/// </summary>
|
||||||
|
public interface ILibraryDiscoveryService
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Returns the titles of the non-hidden document libraries on
|
||||||
|
/// <paramref name="siteUrl"/>, ordered case-insensitively by title.
|
||||||
|
/// Handles elevation + context creation internally.
|
||||||
|
/// </summary>
|
||||||
|
Task<IReadOnlyList<string>> ListLibrariesAsync(
|
||||||
|
TenantProfile profile,
|
||||||
|
string siteUrl,
|
||||||
|
CancellationToken ct = default);
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using Microsoft.SharePoint.Client;
|
||||||
|
using SharepointToolbox.Web.Core.Helpers;
|
||||||
|
using SharepointToolbox.Web.Core.Models;
|
||||||
|
using SharepointToolbox.Web.Services.Session;
|
||||||
|
|
||||||
|
namespace SharepointToolbox.Web.Services;
|
||||||
|
|
||||||
|
public class LibraryDiscoveryService : ILibraryDiscoveryService
|
||||||
|
{
|
||||||
|
private readonly IElevationCoordinator _elevation;
|
||||||
|
private readonly ISessionManager _sessionManager;
|
||||||
|
|
||||||
|
public LibraryDiscoveryService(IElevationCoordinator elevation, ISessionManager sessionManager)
|
||||||
|
{
|
||||||
|
_elevation = elevation;
|
||||||
|
_sessionManager = sessionManager;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<string>> ListLibrariesAsync(
|
||||||
|
TenantProfile profile, string siteUrl, CancellationToken ct = default)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(siteUrl)) return Array.Empty<string>();
|
||||||
|
|
||||||
|
return await _elevation.RunAsync(async c =>
|
||||||
|
{
|
||||||
|
var ctx = await _sessionManager.GetOrCreateContextAsync(siteUrl, profile, c);
|
||||||
|
ctx.Load(ctx.Web, w => w.Lists.Include(l => l.Title, l => l.Hidden, l => l.BaseType));
|
||||||
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, null, c);
|
||||||
|
return (IReadOnlyList<string>)ctx.Web.Lists
|
||||||
|
.Where(l => !l.Hidden && l.BaseType == BaseType.DocumentLibrary)
|
||||||
|
.Select(l => l.Title)
|
||||||
|
.OrderBy(t => t, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToList();
|
||||||
|
}, ct);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -159,6 +159,61 @@ body {
|
|||||||
|
|
||||||
.content { flex: 1; overflow-y: auto; padding: 24px 28px; }
|
.content { flex: 1; overflow-y: auto; padding: 24px 28px; }
|
||||||
|
|
||||||
|
/* ── Top bar + profile selector ── */
|
||||||
|
.topbar {
|
||||||
|
position: sticky; top: -24px; z-index: 30;
|
||||||
|
display: flex; align-items: center; justify-content: flex-end;
|
||||||
|
gap: 12px; margin: -24px -28px 16px; padding: 12px 28px;
|
||||||
|
background: var(--page-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-selector { position: relative; }
|
||||||
|
.profile-selector-trigger {
|
||||||
|
display: flex; align-items: center; gap: 9px;
|
||||||
|
padding: 8px 12px; border-radius: var(--radius-md);
|
||||||
|
border: 1px solid var(--border); background: var(--card-bg); color: var(--text);
|
||||||
|
font-family: inherit; font-size: 13.5px; font-weight: 600; cursor: pointer;
|
||||||
|
box-shadow: 0 2px 8px rgba(30,30,70,.06); transition: background .15s, border-color .15s;
|
||||||
|
max-width: 260px;
|
||||||
|
}
|
||||||
|
.profile-selector-trigger:hover { background: var(--surface-hover); }
|
||||||
|
.profile-selector-trigger.unset { color: var(--text-muted); font-weight: 500; }
|
||||||
|
.profile-selector-icon { font-size: 15px; }
|
||||||
|
.profile-selector-name { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.profile-selector-caret { color: var(--text-muted); font-size: 11px; transition: transform .15s; }
|
||||||
|
.profile-selector-caret.open { transform: rotate(180deg); }
|
||||||
|
|
||||||
|
.profile-selector-backdrop { position: fixed; inset: 0; z-index: 40; }
|
||||||
|
.profile-selector-menu {
|
||||||
|
position: absolute; top: calc(100% + 6px); right: 0; z-index: 50;
|
||||||
|
min-width: 260px; max-width: 320px; padding: 6px;
|
||||||
|
background: var(--card-bg); border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius-md); box-shadow: 0 12px 34px rgba(30,30,70,.18);
|
||||||
|
}
|
||||||
|
.profile-selector-empty { padding: 12px; font-size: 12.5px; color: var(--text-muted); text-align: center; }
|
||||||
|
.profile-selector-item {
|
||||||
|
display: flex; align-items: center; gap: 8px; width: 100%;
|
||||||
|
padding: 9px 11px; border: none; border-radius: var(--radius-sm);
|
||||||
|
background: none; color: var(--text); cursor: pointer; font-family: inherit; text-align: left;
|
||||||
|
transition: background .12s;
|
||||||
|
}
|
||||||
|
.profile-selector-item:hover { background: var(--surface-hover); }
|
||||||
|
.profile-selector-item.active { background: var(--surface-hover); }
|
||||||
|
|
||||||
|
.library-picker-item { transition: background .12s; color: var(--text); }
|
||||||
|
.library-picker-item:hover { background: var(--surface-hover); }
|
||||||
|
.library-picker-item.active { background: var(--surface-hover); font-weight: 600; }
|
||||||
|
.profile-selector-item-text { display: flex; flex-direction: column; min-width: 0; flex: 1; }
|
||||||
|
.profile-selector-item-name { font-size: 13.5px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.profile-selector-item-url { font-size: 11px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||||
|
.profile-selector-check { color: var(--accent); font-weight: 700; flex-shrink: 0; }
|
||||||
|
.profile-selector-manage {
|
||||||
|
display: block; margin-top: 4px; padding: 9px 11px;
|
||||||
|
border-top: 1px solid var(--border); border-radius: 0 0 var(--radius-sm) var(--radius-sm);
|
||||||
|
color: var(--text-muted); text-decoration: none; font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.profile-selector-manage:hover { background: var(--surface-hover); color: var(--text); }
|
||||||
|
|
||||||
/* ── Sidebar dark variant ── */
|
/* ── Sidebar dark variant ── */
|
||||||
[data-theme="dark"] {
|
[data-theme="dark"] {
|
||||||
--page-bg: #15161c;
|
--page-bg: #15161c;
|
||||||
@@ -322,5 +377,10 @@ body {
|
|||||||
.feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; }
|
.feature-card { cursor: pointer; transition: box-shadow .15s, transform .15s; }
|
||||||
.feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); }
|
.feature-card:hover { box-shadow: 0 14px 36px rgba(91, 91, 214, .22); transform: translateY(-2px); }
|
||||||
|
|
||||||
|
/* ── Visual folder-structure builder ── */
|
||||||
|
.folder-builder { display: flex; flex-direction: column; gap: 6px; padding: 10px; border: 1px solid var(--border); border-radius: var(--radius-sm); background: var(--surface); }
|
||||||
|
.folder-node { display: flex; flex-direction: column; gap: 6px; }
|
||||||
|
.folder-node .folder-node { border-left: 1px solid var(--border); padding-left: 6px; }
|
||||||
|
|
||||||
/* ── Theme (light-only palette; System resolves via JS) ── */
|
/* ── Theme (light-only palette; System resolves via JS) ── */
|
||||||
[data-theme="light"] { color-scheme: light; }
|
[data-theme="light"] { color-scheme: light; }
|
||||||
|
|||||||
Reference in New Issue
Block a user