sqlite_vtable_opendal/backends/
dropbox.rs1use crate::backends::StorageBackend;
7use crate::error::Result;
8use crate::types::{FileMetadata, QueryConfig};
9use async_trait::async_trait;
10use futures_util::TryStreamExt;
11use opendal::{services::Dropbox, EntryMode, Operator};
12use std::path::Path;
13
14pub struct DropboxBackend {
34 access_token: String,
36 base_path: String,
38}
39
40impl DropboxBackend {
41 pub fn new(access_token: impl Into<String>, base_path: impl Into<String>) -> Self {
56 Self {
57 access_token: access_token.into(),
58 base_path: base_path.into(),
59 }
60 }
61
62 fn create_operator(&self) -> Result<Operator> {
64 let builder = Dropbox::default()
65 .access_token(&self.access_token)
66 .root(&self.base_path);
67
68 Ok(Operator::new(builder)?
69 .finish())
70 }
71}
72
73#[async_trait]
74impl StorageBackend for DropboxBackend {
75 async fn list_files(&self, config: &QueryConfig) -> Result<Vec<FileMetadata>> {
76 let operator = self.create_operator()?;
77 let mut results = Vec::new();
78
79 let normalized_path = if config.root_path.is_empty() || config.root_path == "/" {
81 "".to_string()
82 } else {
83 let clean_path = config.root_path.trim_matches('/');
84 if clean_path.is_empty() {
85 "".to_string()
86 } else {
87 format!("/{}", clean_path)
88 }
89 };
90
91 let mut lister = operator
93 .lister_with(&normalized_path)
94 .recursive(config.recursive)
95 .await?;
96
97 while let Some(entry) = lister.try_next().await? {
99 let entry_path = entry.path();
100 let entry_mode = entry.metadata().mode();
101
102 if entry_path.is_empty() || entry_path == "/" || entry_path == "." {
104 continue;
105 }
106
107 let full_path = if entry_path.starts_with('/') {
108 entry_path.to_string()
109 } else {
110 format!("/{}", entry_path)
111 };
112
113 let name = Path::new(&full_path)
115 .file_name()
116 .map(|s| s.to_string_lossy().to_string())
117 .unwrap_or_else(|| {
118 let clean_path = entry_path.trim_end_matches('/');
119 Path::new(clean_path)
120 .file_name()
121 .map(|s| s.to_string_lossy().to_string())
122 .unwrap_or_default()
123 });
124
125 if entry_mode == EntryMode::FILE {
126 let metadata = operator
128 .stat(&full_path)
129 .await
130 ?;
131
132 let content = if config.fetch_content {
134 operator
135 .read(&full_path)
136 .await
137 .ok()
138 .map(|bytes| bytes.to_vec())
139 } else {
140 None
141 };
142
143 results.push(FileMetadata {
144 name,
145 path: full_path.clone(),
146 size: metadata.content_length(),
147 last_modified: metadata.last_modified().map(|dt| dt.to_string()),
148 etag: metadata.content_md5().map(|md5| md5.to_string()),
149 is_dir: false,
150 content_type: Path::new(&full_path)
151 .extension()
152 .and_then(|ext| ext.to_str())
153 .map(|ext| ext.to_string()),
154 content,
155 });
156
157 if let Some(limit) = config.limit {
159 if results.len() >= limit + config.offset {
160 break;
161 }
162 }
163 } else if entry_mode == EntryMode::DIR {
164 results.push(FileMetadata {
166 name,
167 path: full_path,
168 size: 0,
169 last_modified: None,
170 etag: None,
171 is_dir: true,
172 content_type: Some("directory".to_string()),
173 content: None,
174 });
175
176 if let Some(limit) = config.limit {
178 if results.len() >= limit + config.offset {
179 break;
180 }
181 }
182 }
183 }
184
185 if config.offset > 0 && config.offset < results.len() {
187 results = results.into_iter().skip(config.offset).collect();
188 }
189
190 Ok(results)
191 }
192
193 fn backend_name(&self) -> &'static str {
194 "dropbox"
195 }
196}
197
198pub fn register(
222 conn: &rusqlite::Connection,
223 module_name: &str,
224 access_token: impl Into<String>,
225 base_path: impl Into<String>,
226) -> rusqlite::Result<()> {
227 use crate::types::{columns, QueryConfig};
228 use rusqlite::{
229 ffi,
230 vtab::{self, eponymous_only_module, IndexInfo, VTab, VTabCursor, VTabKind},
231 };
232 use std::os::raw::c_int;
233
234 let token = access_token.into();
235 let path = base_path.into();
236
237 #[repr(C)]
239 struct DropboxTable {
240 base: ffi::sqlite3_vtab,
241 access_token: String,
242 base_path: String,
243 }
244
245 #[repr(C)]
247 struct DropboxCursor {
248 base: ffi::sqlite3_vtab_cursor,
249 files: Vec<crate::types::FileMetadata>,
250 current_row: usize,
251 access_token: String,
252 base_path: String,
253 }
254
255 impl DropboxCursor {
256 fn new(access_token: String, base_path: String) -> Self {
257 Self {
258 base: ffi::sqlite3_vtab_cursor::default(),
259 files: Vec::new(),
260 current_row: 0,
261 access_token,
262 base_path,
263 }
264 }
265 }
266
267 unsafe impl VTabCursor for DropboxCursor {
268 fn filter(
269 &mut self,
270 _idx_num: c_int,
271 _idx_str: Option<&str>,
272 _args: &vtab::Filters<'_>,
273 ) -> rusqlite::Result<()> {
274 let backend = DropboxBackend::new(&self.access_token, &self.base_path);
276 let config = QueryConfig::default();
277
278 let files = tokio::task::block_in_place(|| {
280 tokio::runtime::Handle::current().block_on(async {
281 backend.list_files(&config).await
282 })
283 })
284 .map_err(|e| rusqlite::Error::ModuleError(e.to_string()))?;
285
286 self.files = files;
287 self.current_row = 0;
288 Ok(())
289 }
290
291 fn next(&mut self) -> rusqlite::Result<()> {
292 self.current_row += 1;
293 Ok(())
294 }
295
296 fn eof(&self) -> bool {
297 self.current_row >= self.files.len()
298 }
299
300 fn column(&self, ctx: &mut vtab::Context, col_index: c_int) -> rusqlite::Result<()> {
301 if self.current_row >= self.files.len() {
302 return Ok(());
303 }
304
305 let file = &self.files[self.current_row];
306
307 match col_index {
308 columns::PATH => ctx.set_result(&file.path),
309 columns::SIZE => ctx.set_result(&(file.size as i64)),
310 columns::LAST_MODIFIED => ctx.set_result(&file.last_modified),
311 columns::ETAG => ctx.set_result(&file.etag),
312 columns::IS_DIR => ctx.set_result(&file.is_dir),
313 columns::CONTENT_TYPE => ctx.set_result(&file.content_type),
314 columns::NAME => ctx.set_result(&file.name),
315 columns::CONTENT => {
316 if let Some(ref content) = file.content {
317 ctx.set_result(&content.as_slice())
318 } else {
319 ctx.set_result::<Option<&[u8]>>(&None)
320 }
321 }
322 _ => Ok(()),
323 }
324 }
325
326 fn rowid(&self) -> rusqlite::Result<i64> {
327 Ok(self.current_row as i64)
328 }
329 }
330
331 impl vtab::CreateVTab<'_> for DropboxTable {
332 const KIND: VTabKind = VTabKind::EponymousOnly;
333 }
334
335 unsafe impl VTab<'_> for DropboxTable {
336 type Aux = (String, String);
337 type Cursor = DropboxCursor;
338
339 fn connect(
340 _db: &mut vtab::VTabConnection,
341 aux: Option<&Self::Aux>,
342 _args: &[&[u8]],
343 ) -> rusqlite::Result<(String, Self)> {
344 let schema = "
345 CREATE TABLE x(
346 path TEXT,
347 size INTEGER,
348 last_modified TEXT,
349 etag TEXT,
350 is_dir INTEGER,
351 content_type TEXT,
352 name TEXT,
353 content BLOB
354 )
355 ";
356
357 let (access_token, base_path) = if let Some((token, path)) = aux {
358 (token.clone(), path.clone())
359 } else {
360 ("/".to_string(), "/".to_string())
361 };
362
363 Ok((
364 schema.to_owned(),
365 DropboxTable {
366 base: ffi::sqlite3_vtab::default(),
367 access_token,
368 base_path,
369 },
370 ))
371 }
372
373 fn best_index(&self, info: &mut IndexInfo) -> rusqlite::Result<()> {
374 info.set_estimated_cost(1000.0);
375 Ok(())
376 }
377
378 fn open(&mut self) -> rusqlite::Result<Self::Cursor> {
379 Ok(DropboxCursor::new(
380 self.access_token.clone(),
381 self.base_path.clone(),
382 ))
383 }
384 }
385
386 conn.create_module(
387 module_name,
388 eponymous_only_module::<DropboxTable>(),
389 Some((token, path)),
390 )
391}
392
393#[cfg(test)]
394mod tests {
395 use super::*;
396
397 #[test]
398 fn test_backend_creation() {
399 let backend = DropboxBackend::new("test_token", "/Documents");
400 assert_eq!(backend.access_token, "test_token");
401 assert_eq!(backend.base_path, "/Documents");
402 assert_eq!(backend.backend_name(), "dropbox");
403 }
404
405 #[test]
406 fn test_backend_with_root_path() {
407 let backend = DropboxBackend::new("token", "/");
408 assert_eq!(backend.base_path, "/");
409 }
410
411 }