commit
96a0f29652
@ -1,10 +1,12 @@
|
||||
pub mod explorer;
|
||||
pub mod share;
|
||||
pub mod site;
|
||||
pub mod user;
|
||||
pub mod workflow;
|
||||
|
||||
// Re-export for convenience
|
||||
pub use explorer::ExplorerApi;
|
||||
pub use share::ShareApi;
|
||||
pub use site::SiteApi;
|
||||
pub use user::UserApi;
|
||||
pub use workflow::WorkflowApi;
|
||||
|
||||
@ -0,0 +1,18 @@
|
||||
use crate::client::{Client, RequestOptions};
|
||||
use crate::error::ApiResult;
|
||||
use crate::models::share::*;
|
||||
use async_trait::async_trait;
|
||||
|
||||
/// Share API methods
|
||||
#[async_trait]
|
||||
pub trait ShareApi {
|
||||
/// Create a share link for a file or folder, returns the share URL.
|
||||
async fn create_share(&self, request: &ShareCreateService) -> ApiResult<String>;
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ShareApi for Client {
|
||||
async fn create_share(&self, request: &ShareCreateService) -> ApiResult<String> {
|
||||
self.put("/share", request, RequestOptions::new()).await
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,142 @@
|
||||
use crate::drive::manager::DriveManager;
|
||||
use crate::{drive::commands::ManagerCommand, utils::app::AppRoot};
|
||||
use rust_i18n::t;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use windows::{
|
||||
Win32::{Foundation::*, System::Com::*, System::Ole::*, UI::Shell::*},
|
||||
core::*,
|
||||
};
|
||||
|
||||
/// shlguid.h: SID_SFolderView — resolves IFolderView from the command site.
|
||||
const SID_S_FOLDER_VIEW: GUID = GUID::from_u128(0xcde725b0_ccc9_4519_917e_325d72fab4ce);
|
||||
|
||||
#[implement(IExplorerCommand, IObjectWithSite)]
|
||||
pub struct ShareLinkCommandHandler {
|
||||
drive_manager: Arc<DriveManager>,
|
||||
app_root: AppRoot,
|
||||
site: Mutex<Option<IUnknown>>,
|
||||
}
|
||||
|
||||
impl ShareLinkCommandHandler {
|
||||
pub fn new(drive_manager: Arc<DriveManager>, app_root: AppRoot) -> Self {
|
||||
Self {
|
||||
drive_manager,
|
||||
app_root,
|
||||
site: Mutex::new(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn send_copy_share_link(&self, path: PathBuf) {
|
||||
tracing::debug!(target: "shellext::context_menu", path = %path.display(), "Copy share link requested");
|
||||
let command_tx = self.drive_manager.get_command_sender();
|
||||
if let Err(e) = command_tx.send(ManagerCommand::CopyShareLink { path }) {
|
||||
tracing::error!(target: "shellext::context_menu", error = %e, "Failed to send CopyShareLink command");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IObjectWithSite_Impl for ShareLinkCommandHandler_Impl {
|
||||
fn SetSite(&self, punksite: Option<&IUnknown>) -> Result<()> {
|
||||
*self.site.lock().unwrap() = punksite.cloned();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn GetSite(&self, riid: *const GUID, ppvsite: *mut *mut core::ffi::c_void) -> Result<()> {
|
||||
let site = self.site.lock().unwrap();
|
||||
if let Some(site) = site.as_ref() {
|
||||
unsafe { site.query(riid, ppvsite) }.ok()
|
||||
} else {
|
||||
Err(Error::from(E_FAIL))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl IExplorerCommand_Impl for ShareLinkCommandHandler_Impl {
|
||||
fn GetTitle(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
|
||||
let title = t!("copyShareLink");
|
||||
let hstring = HSTRING::from(title.as_ref());
|
||||
unsafe { SHStrDupW(&hstring) }
|
||||
}
|
||||
|
||||
fn GetIcon(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
|
||||
let icon_path = format!("{}\\people.ico", self.app_root.image_path());
|
||||
let hstring = HSTRING::from(icon_path);
|
||||
unsafe { SHStrDupW(&hstring) }
|
||||
}
|
||||
|
||||
fn GetToolTip(&self, _items: Option<&IShellItemArray>) -> Result<PWSTR> {
|
||||
Err(Error::from(E_NOTIMPL))
|
||||
}
|
||||
|
||||
fn GetCanonicalName(&self) -> Result<GUID> {
|
||||
Ok(GUID::from_u128(0x7d2b8f1c_3a9e_4c5d_b6f2_9e8a1d4c6f0b))
|
||||
}
|
||||
|
||||
fn GetState(&self, items: Option<&IShellItemArray>, _oktobeslow: BOOL) -> Result<u32> {
|
||||
let Some(items) = items else {
|
||||
// Not select anthing, but still triggerd from a folder
|
||||
return Ok(ECS_ENABLED.0 as u32);
|
||||
};
|
||||
|
||||
unsafe {
|
||||
let count = items.GetCount()?;
|
||||
if count <= 1 {
|
||||
Ok(ECS_ENABLED.0 as u32)
|
||||
} else {
|
||||
Ok(ECS_HIDDEN.0 as u32)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn Invoke(
|
||||
&self,
|
||||
selection: Option<&IShellItemArray>,
|
||||
_bindctx: Option<&IBindCtx>,
|
||||
) -> Result<()> {
|
||||
tracing::debug!(target: "shellext::context_menu", "Copy share link context menu command invoked");
|
||||
|
||||
if let Some(items) = selection {
|
||||
unsafe {
|
||||
let count = items.GetCount()?;
|
||||
if count != 1 {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
// Get the first item
|
||||
let item = items.GetItemAt(0)?;
|
||||
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
|
||||
let path = PathBuf::from(display_name.to_string()?);
|
||||
|
||||
self.send_copy_share_link(path);
|
||||
}
|
||||
} else {
|
||||
// Folder-background invoke carries no selection; resolve the
|
||||
// current folder through the site Explorer gave us in SetSite.
|
||||
let site = self.site.lock().unwrap().clone();
|
||||
let Some(site) = site else {
|
||||
return Ok(());
|
||||
};
|
||||
unsafe {
|
||||
let service_provider: IServiceProvider = site.cast()?;
|
||||
let folder_view: IFolderView =
|
||||
service_provider.QueryService(&SID_S_FOLDER_VIEW)?;
|
||||
let item: IShellItem = folder_view.GetFolder()?;
|
||||
let display_name = item.GetDisplayName(SIGDN_FILESYSPATH)?;
|
||||
let path = PathBuf::from(display_name.to_string()?);
|
||||
|
||||
self.send_copy_share_link(path);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn GetFlags(&self) -> Result<u32> {
|
||||
Ok(ECF_DEFAULT.0 as u32)
|
||||
}
|
||||
|
||||
fn EnumSubCommands(&self) -> Result<IEnumExplorerCommand> {
|
||||
Err(Error::from(E_NOTIMPL))
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,39 @@
|
||||
//! Minimal clipboard helpers. Only Windows sets real clipboard text; other
|
||||
//! platforms no-op so callers can stay unconditional.
|
||||
|
||||
#[cfg(windows)]
|
||||
pub fn set_text(text: &str) -> anyhow::Result<()> {
|
||||
use windows::Win32::{
|
||||
Foundation::HANDLE,
|
||||
System::{
|
||||
DataExchange::{CloseClipboard, EmptyClipboard, OpenClipboard, SetClipboardData},
|
||||
Memory::{GMEM_MOVEABLE, GlobalAlloc, GlobalLock, GlobalUnlock},
|
||||
Ole::CF_UNICODETEXT,
|
||||
},
|
||||
};
|
||||
|
||||
unsafe {
|
||||
OpenClipboard(None)?;
|
||||
let result = (|| -> anyhow::Result<()> {
|
||||
EmptyClipboard()?;
|
||||
let wide: Vec<u16> = text.encode_utf16().chain(std::iter::once(0)).collect();
|
||||
let hglobal = GlobalAlloc(GMEM_MOVEABLE, wide.len() * 2)?;
|
||||
let dst = GlobalLock(hglobal);
|
||||
if dst.is_null() {
|
||||
return Err(anyhow::anyhow!("GlobalLock failed"));
|
||||
}
|
||||
std::ptr::copy_nonoverlapping(wide.as_ptr(), dst as *mut u16, wide.len());
|
||||
let _ = GlobalUnlock(hglobal);
|
||||
SetClipboardData(CF_UNICODETEXT.0 as u32, HANDLE(hglobal.0))?;
|
||||
Ok(())
|
||||
})();
|
||||
let _ = CloseClipboard();
|
||||
result
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
pub fn set_text(_text: &str) -> anyhow::Result<()> {
|
||||
tracing::warn!(target: "utils::clipboard", "Clipboard set_text is not supported on this platform");
|
||||
Ok(())
|
||||
}
|
||||
@ -1,2 +1,3 @@
|
||||
pub mod app;
|
||||
pub mod clipboard;
|
||||
pub mod toast;
|
||||
|
||||
@ -0,0 +1,141 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
iofs "io/fs"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/fs"
|
||||
"github.com/cloudreve/Cloudreve/v4/pkg/filemanager/manager/entitysource"
|
||||
)
|
||||
|
||||
// Multi-volume archive naming conventions. rardecode derives the next volume
|
||||
// name from the current one and opens it via a custom fs.FS, so siblings only
|
||||
// need to be resolvable by bare file name within the archive's folder.
|
||||
// Only RAR conventions are recognized: it is the format rardecode can follow
|
||||
// across volumes via fs.FS. Split zip/7z sets are intentionally excluded as
|
||||
// their extractors cannot span files.
|
||||
var (
|
||||
newRarVolumeRe = regexp.MustCompile(`^(.+)\.part(\d+)\.rar$`)
|
||||
oldRarVolumeRe = regexp.MustCompile(`^(.+)\.r(\d+)$`)
|
||||
)
|
||||
|
||||
// volumeInfo returns the volume-set key and the ordering number of a file
|
||||
// name. ok is false when the name is not a recognizable multi-volume member.
|
||||
// The key is only comparable within one folder.
|
||||
func volumeInfo(name string) (key string, order int64, ok bool) {
|
||||
lower := strings.ToLower(name)
|
||||
if m := newRarVolumeRe.FindStringSubmatch(lower); m != nil {
|
||||
n, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
return "rar:" + m[1], n, true
|
||||
}
|
||||
if m := oldRarVolumeRe.FindStringSubmatch(lower); m != nil {
|
||||
n, _ := strconv.ParseInt(m[2], 10, 64)
|
||||
return "rarold:" + m[1], n + 1, true
|
||||
}
|
||||
if stem, found := strings.CutSuffix(lower, ".rar"); found {
|
||||
return "rarold:" + stem, 0, true
|
||||
}
|
||||
return "", 0, false
|
||||
}
|
||||
|
||||
// firstVolumeName returns the expected file name of the first volume of the
|
||||
// set the given member belongs to, or "" when the name already is the first
|
||||
// volume or is not a recognizable later volume.
|
||||
func firstVolumeName(name string) string {
|
||||
lower := strings.ToLower(name)
|
||||
if m := newRarVolumeRe.FindStringSubmatchIndex(lower); m != nil {
|
||||
if n, _ := strconv.Atoi(lower[m[4]:m[5]]); n > 1 {
|
||||
return fmt.Sprintf("%s.part%0*d.rar", name[m[2]:m[3]], len(lower[m[4]:m[5]]), 1)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
if m := oldRarVolumeRe.FindStringSubmatchIndex(lower); m != nil {
|
||||
return name[m[2]:m[3]] + ".rar"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// FirstVolumeURI inspects a multi-file selection of cloudreve URIs and, when
|
||||
// all files are volumes of the same archive set in the same folder, returns
|
||||
// the URI of the first volume to extract from. ok is false when the
|
||||
// selection is not a single volume set.
|
||||
func FirstVolumeURI(srcs []string) (src string, ok bool) {
|
||||
if len(srcs) < 2 {
|
||||
return "", false
|
||||
}
|
||||
var key, dir string
|
||||
best, bestOrder := -1, int64(0)
|
||||
for i, s := range srcs {
|
||||
u, err := fs.NewUriFromString(s)
|
||||
if err != nil {
|
||||
return "", false
|
||||
}
|
||||
k, order, isVolume := volumeInfo(u.Name())
|
||||
if !isVolume {
|
||||
return "", false
|
||||
}
|
||||
if i == 0 {
|
||||
key, dir = k, u.Dir()
|
||||
} else if k != key || u.Dir() != dir {
|
||||
return "", false
|
||||
}
|
||||
if best < 0 || order < bestOrder {
|
||||
best, bestOrder = i, order
|
||||
}
|
||||
}
|
||||
return srcs[best], true
|
||||
}
|
||||
|
||||
// archiveVolumeFS is an fs.FS resolving bare archive volume file names to
|
||||
// entity sources. rardecode calls Open with volume names derived from the
|
||||
// first volume's name; a missing sibling must surface fs.ErrNotExist so the
|
||||
// decoder treats it as the end of the archive.
|
||||
type archiveVolumeFS struct {
|
||||
open func(name string) (entitysource.EntitySource, error)
|
||||
}
|
||||
|
||||
func (f *archiveVolumeFS) Open(name string) (iofs.File, error) {
|
||||
if name == "" || strings.ContainsRune(name, '/') || strings.ContainsRune(name, '\\') {
|
||||
return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist}
|
||||
}
|
||||
|
||||
es, err := f.open(name)
|
||||
if err != nil || es == nil {
|
||||
return nil, &iofs.PathError{Op: "open", Path: name, Err: iofs.ErrNotExist}
|
||||
}
|
||||
|
||||
return &archiveVolumeFile{es: es, name: name}, nil
|
||||
}
|
||||
|
||||
// archiveVolumeFile adapts an entity source to fs.File while keeping the
|
||||
// io.Seeker passthrough rardecode uses for in-volume seeks.
|
||||
type archiveVolumeFile struct {
|
||||
es entitysource.EntitySource
|
||||
name string
|
||||
}
|
||||
|
||||
func (f *archiveVolumeFile) Read(p []byte) (int, error) { return f.es.Read(p) }
|
||||
func (f *archiveVolumeFile) Seek(o int64, w int) (int64, error) { return f.es.Seek(o, w) }
|
||||
func (f *archiveVolumeFile) Close() error { return f.es.Close() }
|
||||
func (f *archiveVolumeFile) Stat() (iofs.FileInfo, error) {
|
||||
return archiveVolumeFileInfo{name: f.name, size: f.es.Entity().Size()}, nil
|
||||
}
|
||||
|
||||
var _ io.Seeker = (*archiveVolumeFile)(nil)
|
||||
|
||||
type archiveVolumeFileInfo struct {
|
||||
name string
|
||||
size int64
|
||||
}
|
||||
|
||||
func (i archiveVolumeFileInfo) Name() string { return i.name }
|
||||
func (i archiveVolumeFileInfo) Size() int64 { return i.size }
|
||||
func (i archiveVolumeFileInfo) Mode() iofs.FileMode { return 0 }
|
||||
func (i archiveVolumeFileInfo) ModTime() time.Time { return time.Time{} }
|
||||
func (i archiveVolumeFileInfo) IsDir() bool { return false }
|
||||
func (i archiveVolumeFileInfo) Sys() any { return nil }
|
||||
@ -0,0 +1,83 @@
|
||||
package workflows
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestVolumeInfo(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
key string
|
||||
order int64
|
||||
ok bool
|
||||
}{
|
||||
{"movie.part1.rar", "rar:movie", 1, true},
|
||||
{"movie.part02.rar", "rar:movie", 2, true},
|
||||
{"MOVIE.PART10.RAR", "rar:movie", 10, true},
|
||||
{"movie.rar", "rarold:movie", 0, true},
|
||||
{"movie.r00", "rarold:movie", 1, true},
|
||||
{"movie.r15", "rarold:movie", 16, true},
|
||||
{"movie.zip", "", 0, false},
|
||||
{"movie.z01", "", 0, false},
|
||||
{"movie.001", "", 0, false},
|
||||
{"movie.7z", "", 0, false},
|
||||
{"movie.tar.gz", "", 0, false},
|
||||
{"readme.txt", "", 0, false},
|
||||
}
|
||||
for _, c := range cases {
|
||||
key, order, ok := volumeInfo(c.name)
|
||||
require.Equal(t, c.ok, ok, c.name)
|
||||
if c.ok {
|
||||
require.Equal(t, c.key, key, c.name)
|
||||
require.Equal(t, c.order, order, c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstVolumeName(t *testing.T) {
|
||||
require.Equal(t, "movie.part01.rar", firstVolumeName("movie.part07.rar"))
|
||||
require.Equal(t, "movie.part1.rar", firstVolumeName("movie.part3.rar"))
|
||||
require.Equal(t, "movie.rar", firstVolumeName("movie.r02"))
|
||||
require.Equal(t, "", firstVolumeName("movie.part1.rar"))
|
||||
require.Equal(t, "", firstVolumeName("movie.rar"))
|
||||
require.Equal(t, "", firstVolumeName("movie.zip"))
|
||||
require.Equal(t, "", firstVolumeName("movie.001"))
|
||||
require.Equal(t, "", firstVolumeName("plain.txt"))
|
||||
}
|
||||
|
||||
func TestFirstVolumeURI(t *testing.T) {
|
||||
base := "cloudreve://my/archives"
|
||||
src, ok := FirstVolumeURI([]string{
|
||||
base + "/movie.part2.rar",
|
||||
base + "/movie.part1.rar",
|
||||
base + "/movie.part3.rar",
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, base+"/movie.part1.rar", src)
|
||||
|
||||
// Old-style set: .rar is first.
|
||||
src, ok = FirstVolumeURI([]string{
|
||||
base + "/movie.r00",
|
||||
base + "/movie.rar",
|
||||
})
|
||||
require.True(t, ok)
|
||||
require.Equal(t, base+"/movie.rar", src)
|
||||
|
||||
// Single source never matches.
|
||||
_, ok = FirstVolumeURI([]string{base + "/movie.part1.rar"})
|
||||
require.False(t, ok)
|
||||
|
||||
// Mixed sets do not match.
|
||||
_, ok = FirstVolumeURI([]string{base + "/a.part1.rar", base + "/b.part2.rar"})
|
||||
require.False(t, ok)
|
||||
|
||||
// Different folders do not match.
|
||||
_, ok = FirstVolumeURI([]string{base + "/movie.part1.rar", "cloudreve://my/other/movie.part2.rar"})
|
||||
require.False(t, ok)
|
||||
|
||||
// Non-volume names do not match.
|
||||
_, ok = FirstVolumeURI([]string{base + "/a.txt", base + "/b.txt"})
|
||||
require.False(t, ok)
|
||||
}
|
||||
Loading…
Reference in new issue