mirror of
https://github.com/thegeneralist01/archivr
synced 2026-10-09 12:55:00 +02:00
Merge branch 'feat/archive-text-with-title' into integration-all-three
This commit is contained in:
commit
5b5b889cb4
9 changed files with 929 additions and 65 deletions
|
|
@ -1918,6 +1918,172 @@ pub fn perform_capture(
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Archives user-supplied plain text or Markdown content.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `archive_paths` - Path configuration for the archive
|
||||||
|
/// * `title` - User-supplied title (non-empty, trimmed, capped at 500 chars)
|
||||||
|
/// * `body` - Text content (non-empty, capped at 2 MiB)
|
||||||
|
/// * `mime` - MIME type: "text/plain" or "text/markdown"
|
||||||
|
/// * `archive_id` - Optional archive ID (used for job tracking if provided)
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `CaptureResult` with the run UID and status
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// * Empty or oversized title/body
|
||||||
|
/// * Unsupported MIME type
|
||||||
|
/// * Database or file system errors
|
||||||
|
pub fn perform_text_capture(
|
||||||
|
archive_paths: &ArchivePaths,
|
||||||
|
title: &str,
|
||||||
|
body: &str,
|
||||||
|
mime: &str,
|
||||||
|
_archive_id: Option<&str>,
|
||||||
|
) -> Result<CaptureResult> {
|
||||||
|
// Validate title
|
||||||
|
let title = title.trim();
|
||||||
|
if title.is_empty() {
|
||||||
|
anyhow::bail!("title must not be empty");
|
||||||
|
}
|
||||||
|
if title.len() > 500 {
|
||||||
|
anyhow::bail!("title must not exceed 500 characters");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate body
|
||||||
|
let body = body.trim();
|
||||||
|
if body.is_empty() {
|
||||||
|
anyhow::bail!("body must not be empty");
|
||||||
|
}
|
||||||
|
if body.len() > 2 * 1024 * 1024 {
|
||||||
|
anyhow::bail!("body must not exceed 2 MiB");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
if mime != "text/plain" && mime != "text/markdown" {
|
||||||
|
anyhow::bail!("unsupported MIME type: {mime}. Must be 'text/plain' or 'text/markdown'");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate timestamp
|
||||||
|
let timestamp = format!(
|
||||||
|
"{}-{}",
|
||||||
|
Local::now().format("%Y-%m-%dT%H-%M-%S%.3f"),
|
||||||
|
Uuid::new_v4().simple(),
|
||||||
|
);
|
||||||
|
let store_path = &archive_paths.store_path;
|
||||||
|
|
||||||
|
// Initialize database
|
||||||
|
let conn = database::open_or_initialize(&archive_paths.archive_path)?;
|
||||||
|
let user_id = database::ensure_default_user(&conn)?;
|
||||||
|
|
||||||
|
// Create run and item
|
||||||
|
let run = database::create_archive_run(&conn, user_id, 1)?;
|
||||||
|
let source_kind = "text";
|
||||||
|
let entity_kind = "document";
|
||||||
|
let item = database::create_archive_run_item(
|
||||||
|
&conn,
|
||||||
|
run.id,
|
||||||
|
None,
|
||||||
|
0,
|
||||||
|
&format!("text:{}", title),
|
||||||
|
None,
|
||||||
|
source_kind,
|
||||||
|
entity_kind,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Stage the text content
|
||||||
|
let staged_text = downloader::text::save(body.as_bytes(), mime, store_path, ×tamp)?;
|
||||||
|
|
||||||
|
// Check if hash already exists
|
||||||
|
let file_extension = format!(".{}", staged_text.extension);
|
||||||
|
let hash_exists = hash_exists(&staged_text.hash, &file_extension, store_path)?;
|
||||||
|
|
||||||
|
if !hash_exists {
|
||||||
|
// Move staged file to raw storage
|
||||||
|
move_temp_to_raw(&staged_text.staged_path, &staged_text.hash, store_path)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up temp directory
|
||||||
|
let _ = fs::remove_dir_all(store_path.join("temp").join(×tamp));
|
||||||
|
|
||||||
|
// Create blob record
|
||||||
|
let raw_relpath = raw_relative_path_from_hash(&staged_text.hash, &file_extension)?;
|
||||||
|
let blob = database::BlobRecord {
|
||||||
|
sha256: staged_text.hash.clone(),
|
||||||
|
byte_size: staged_text.byte_size as i64,
|
||||||
|
mime_type: Some(mime.to_string()),
|
||||||
|
extension: Some(staged_text.extension.clone()),
|
||||||
|
raw_relpath: path_to_store_string(&raw_relpath),
|
||||||
|
};
|
||||||
|
let blob_id = database::upsert_blob(&conn, &blob)?;
|
||||||
|
|
||||||
|
// Create source identity
|
||||||
|
let canonical_locator = format!("text:{}", staged_text.hash);
|
||||||
|
let source_identity_id = database::upsert_source_identity(
|
||||||
|
&conn,
|
||||||
|
source_kind,
|
||||||
|
entity_kind,
|
||||||
|
None,
|
||||||
|
Some(&canonical_locator),
|
||||||
|
&canonical_locator,
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Create entry
|
||||||
|
let entry = database::create_archived_entry(
|
||||||
|
&conn,
|
||||||
|
&database::NewEntry {
|
||||||
|
source_identity_id,
|
||||||
|
archive_run_id: run.id,
|
||||||
|
parent_entry_id: None,
|
||||||
|
root_entry_id: None,
|
||||||
|
created_by_user_id: user_id,
|
||||||
|
owned_by_user_id: user_id,
|
||||||
|
source_kind: source_kind.to_string(),
|
||||||
|
entity_kind: entity_kind.to_string(),
|
||||||
|
title: Some(title.to_string()),
|
||||||
|
visibility: "private".to_string(),
|
||||||
|
representation_kind: "text".to_string(),
|
||||||
|
source_metadata_json: json!({
|
||||||
|
"requested_locator": format!("text:{}", title),
|
||||||
|
"canonical_locator": canonical_locator,
|
||||||
|
"mime_type": mime
|
||||||
|
})
|
||||||
|
.to_string(),
|
||||||
|
display_metadata_json: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Create structured root directory
|
||||||
|
create_structured_root(store_path, &entry)?;
|
||||||
|
|
||||||
|
// Create primary_media artifact
|
||||||
|
database::add_entry_artifact(
|
||||||
|
&conn,
|
||||||
|
&database::NewArtifact {
|
||||||
|
entry_id: entry.id,
|
||||||
|
artifact_role: "primary_media".to_string(),
|
||||||
|
storage_area: "raw".to_string(),
|
||||||
|
relpath: blob.raw_relpath,
|
||||||
|
blob_id: Some(blob_id),
|
||||||
|
logical_path: None,
|
||||||
|
metadata_json: None,
|
||||||
|
},
|
||||||
|
)?;
|
||||||
|
|
||||||
|
// Complete the run item
|
||||||
|
database::complete_archive_run_item(&conn, item.id, entry.id)?;
|
||||||
|
database::refresh_entry_cached_bytes(&conn, entry.id)?;
|
||||||
|
database::finish_archive_run(&conn, run.id)?;
|
||||||
|
|
||||||
|
Ok(CaptureResult {
|
||||||
|
run_uid: run.run_uid.clone(),
|
||||||
|
status: "completed".to_string(),
|
||||||
|
completed_child_count: 0,
|
||||||
|
ublock_skipped: false,
|
||||||
|
cookie_ext_skipped: false,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
/// Result of a tweet re-archive operation.
|
/// Result of a tweet re-archive operation.
|
||||||
#[derive(Debug, serde::Serialize)]
|
#[derive(Debug, serde::Serialize)]
|
||||||
pub struct RearchiveResult {
|
pub struct RearchiveResult {
|
||||||
|
|
@ -2628,6 +2794,195 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_capture_markdown() {
|
||||||
|
let base_path = env::temp_dir().join(format!(
|
||||||
|
"archivr-text-test-{}",
|
||||||
|
Local::now().format("%Y%m%d%H%M%S%3f")
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
fs::create_dir_all(&base_path).unwrap();
|
||||||
|
|
||||||
|
let store_path = base_path.join("store");
|
||||||
|
let archive_path = base_path.join(".archivr");
|
||||||
|
|
||||||
|
// Initialize archive structure
|
||||||
|
archive::initialize_store_directories(&store_path).unwrap();
|
||||||
|
fs::create_dir_all(&archive_path).unwrap();
|
||||||
|
fs::write(archive_path.join("name"), "test-archive").unwrap();
|
||||||
|
fs::write(archive_path.join("store_path"), store_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let archive_paths = ArchivePaths {
|
||||||
|
archive_path: archive_path.clone(),
|
||||||
|
store_path: store_path.clone(),
|
||||||
|
name: "test-archive".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = "My Markdown Note";
|
||||||
|
let body = "# Heading\n\nSome **bold** text.";
|
||||||
|
let mime = "text/markdown";
|
||||||
|
|
||||||
|
let result = perform_text_capture(&archive_paths, title, body, mime, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.status, "completed");
|
||||||
|
assert_eq!(result.completed_child_count, 0);
|
||||||
|
assert!(!result.ublock_skipped);
|
||||||
|
assert!(!result.cookie_ext_skipped);
|
||||||
|
|
||||||
|
// Verify entry was created
|
||||||
|
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||||
|
let default_coll_id = database::ensure_default_collection(&conn).unwrap();
|
||||||
|
let entries = archive::list_entries_for_collection(&conn, default_coll_id, 0xFFFFFFFF).unwrap();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
let entry = &entries[0];
|
||||||
|
assert_eq!(entry.title, Some(title.to_string()));
|
||||||
|
assert_eq!(entry.source_kind, "text");
|
||||||
|
assert_eq!(entry.entity_kind, "document");
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_capture_plain() {
|
||||||
|
let base_path = env::temp_dir().join(format!(
|
||||||
|
"archivr-text-plain-test-{}",
|
||||||
|
Local::now().format("%Y%m%d%H%M%S%3f")
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
fs::create_dir_all(&base_path).unwrap();
|
||||||
|
|
||||||
|
let store_path = base_path.join("store");
|
||||||
|
let archive_path = base_path.join(".archivr");
|
||||||
|
|
||||||
|
// Initialize archive structure
|
||||||
|
archive::initialize_store_directories(&store_path).unwrap();
|
||||||
|
fs::create_dir_all(&archive_path).unwrap();
|
||||||
|
fs::write(archive_path.join("name"), "test-archive").unwrap();
|
||||||
|
fs::write(archive_path.join("store_path"), store_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let archive_paths = ArchivePaths {
|
||||||
|
archive_path: archive_path.clone(),
|
||||||
|
store_path: store_path.clone(),
|
||||||
|
name: "test-archive".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let title = "Plain Text Note";
|
||||||
|
let body = "Just plain text content.";
|
||||||
|
let mime = "text/plain";
|
||||||
|
|
||||||
|
let result = perform_text_capture(&archive_paths, title, body, mime, None).unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.status, "completed");
|
||||||
|
|
||||||
|
// Verify entry was created
|
||||||
|
let conn = database::open_or_initialize(&archive_path).unwrap();
|
||||||
|
let default_coll_id = database::ensure_default_collection(&conn).unwrap();
|
||||||
|
let entries = archive::list_entries_for_collection(&conn, default_coll_id, 0xFFFFFFFF).unwrap();
|
||||||
|
assert_eq!(entries.len(), 1);
|
||||||
|
let entry = &entries[0];
|
||||||
|
assert_eq!(entry.title, Some(title.to_string()));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_capture_rejects_empty_title() {
|
||||||
|
let base_path = env::temp_dir().join(format!(
|
||||||
|
"archivr-text-empty-title-test-{}",
|
||||||
|
Local::now().format("%Y%m%d%H%M%S%3f")
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
fs::create_dir_all(&base_path).unwrap();
|
||||||
|
|
||||||
|
let store_path = base_path.join("store");
|
||||||
|
let archive_path = base_path.join(".archivr");
|
||||||
|
archive::initialize_store_directories(&store_path).unwrap();
|
||||||
|
fs::create_dir_all(&archive_path).unwrap();
|
||||||
|
fs::write(archive_path.join("name"), "test-archive").unwrap();
|
||||||
|
fs::write(archive_path.join("store_path"), store_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let archive_paths = ArchivePaths {
|
||||||
|
archive_path,
|
||||||
|
store_path,
|
||||||
|
name: "test-archive".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = perform_text_capture(&archive_paths, "", "Some body", "text/plain", None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("title must not be empty"));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_capture_rejects_empty_body() {
|
||||||
|
let base_path = env::temp_dir().join(format!(
|
||||||
|
"archivr-text-empty-body-test-{}",
|
||||||
|
Local::now().format("%Y%m%d%H%M%S%3f")
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
fs::create_dir_all(&base_path).unwrap();
|
||||||
|
|
||||||
|
let store_path = base_path.join("store");
|
||||||
|
let archive_path = base_path.join(".archivr");
|
||||||
|
archive::initialize_store_directories(&store_path).unwrap();
|
||||||
|
fs::create_dir_all(&archive_path).unwrap();
|
||||||
|
fs::write(archive_path.join("name"), "test-archive").unwrap();
|
||||||
|
fs::write(archive_path.join("store_path"), store_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let archive_paths = ArchivePaths {
|
||||||
|
archive_path,
|
||||||
|
store_path,
|
||||||
|
name: "test-archive".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = perform_text_capture(&archive_paths, "Some Title", "", "text/plain", None);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("body must not be empty"));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_text_capture_rejects_bad_mime() {
|
||||||
|
let base_path = env::temp_dir().join(format!(
|
||||||
|
"archivr-text-bad-mime-test-{}",
|
||||||
|
Local::now().format("%Y%m%d%H%M%S%3f")
|
||||||
|
));
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
fs::create_dir_all(&base_path).unwrap();
|
||||||
|
|
||||||
|
let store_path = base_path.join("store");
|
||||||
|
let archive_path = base_path.join(".archivr");
|
||||||
|
archive::initialize_store_directories(&store_path).unwrap();
|
||||||
|
fs::create_dir_all(&archive_path).unwrap();
|
||||||
|
fs::write(archive_path.join("name"), "test-archive").unwrap();
|
||||||
|
fs::write(archive_path.join("store_path"), store_path.to_str().unwrap()).unwrap();
|
||||||
|
|
||||||
|
let archive_paths = ArchivePaths {
|
||||||
|
archive_path,
|
||||||
|
store_path,
|
||||||
|
name: "test-archive".to_string(),
|
||||||
|
};
|
||||||
|
|
||||||
|
let result = perform_text_capture(
|
||||||
|
&archive_paths,
|
||||||
|
"Title",
|
||||||
|
"Body",
|
||||||
|
"text/html",
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("unsupported MIME type"));
|
||||||
|
|
||||||
|
// Clean up
|
||||||
|
let _ = fs::remove_dir_all(&base_path);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_initialize_store_directories() {
|
fn test_initialize_store_directories() {
|
||||||
let store_path = env::temp_dir().join(format!(
|
let store_path = env::temp_dir().join(format!(
|
||||||
|
|
|
||||||
|
|
@ -7,3 +7,4 @@ pub mod metadata;
|
||||||
pub mod http;
|
pub mod http;
|
||||||
pub mod singlefile;
|
pub mod singlefile;
|
||||||
pub mod font_extractor;
|
pub mod font_extractor;
|
||||||
|
pub mod text;
|
||||||
|
|
|
||||||
118
crates/archivr-core/src/downloader/text.rs
Normal file
118
crates/archivr-core/src/downloader/text.rs
Normal file
|
|
@ -0,0 +1,118 @@
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use crate::hash::hash_bytes;
|
||||||
|
|
||||||
|
/// Represents a staged text file ready to be moved into the raw store.
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct StagedText {
|
||||||
|
pub staged_path: PathBuf,
|
||||||
|
pub hash: String,
|
||||||
|
pub extension: String,
|
||||||
|
pub byte_size: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stages a text body (plain or Markdown) in the temp directory and computes its hash.
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `body` - The raw bytes of the text content
|
||||||
|
/// * `mime` - MIME type, must be "text/plain" or "text/markdown"
|
||||||
|
/// * `store_path` - Root store path where temp/ subdirectory will be created
|
||||||
|
/// * `timestamp` - Timestamp string used in the staged file name
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// * `StagedText` with the staged path, hash, extension, and byte size
|
||||||
|
///
|
||||||
|
/// # Errors
|
||||||
|
/// * Rejects MIME types other than "text/plain" or "text/markdown"
|
||||||
|
/// * IO errors during directory creation or file writing
|
||||||
|
pub fn save(body: &[u8], mime: &str, store_path: &Path, timestamp: &str) -> Result<StagedText> {
|
||||||
|
// Validate MIME type
|
||||||
|
let extension = match mime {
|
||||||
|
"text/markdown" => ".md",
|
||||||
|
"text/plain" => ".txt",
|
||||||
|
_ => bail!("unsupported MIME type: {mime}. Must be 'text/plain' or 'text/markdown'"),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Create temp directory
|
||||||
|
let temp_dir = store_path.join("temp").join(timestamp);
|
||||||
|
std::fs::create_dir_all(&temp_dir)?;
|
||||||
|
|
||||||
|
// Stage under temp/<timestamp>/<timestamp><ext>
|
||||||
|
let staged_path = temp_dir.join(format!("{timestamp}{extension}"));
|
||||||
|
|
||||||
|
// Write the content
|
||||||
|
std::fs::write(&staged_path, body)?;
|
||||||
|
|
||||||
|
// Compute SHA3 hash
|
||||||
|
let hash = hash_bytes(body);
|
||||||
|
let byte_size = body.len() as u64;
|
||||||
|
let extension_str = extension.trim_start_matches('.').to_string();
|
||||||
|
|
||||||
|
Ok(StagedText {
|
||||||
|
staged_path,
|
||||||
|
hash,
|
||||||
|
extension: extension_str,
|
||||||
|
byte_size,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use tempfile::TempDir;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_markdown() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let store_path = temp_dir.path();
|
||||||
|
let content = b"# Hello\n\nThis is markdown.";
|
||||||
|
let mime = "text/markdown";
|
||||||
|
|
||||||
|
let result = save(content, mime, store_path, "2024-01-01T12-00-00.000-abc123").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.extension, "md");
|
||||||
|
assert_eq!(result.byte_size, content.len() as u64);
|
||||||
|
assert!(result.staged_path.exists());
|
||||||
|
assert_eq!(std::fs::read(&result.staged_path).unwrap(), content);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_plain_text() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let store_path = temp_dir.path();
|
||||||
|
let content = b"Plain text content";
|
||||||
|
let mime = "text/plain";
|
||||||
|
|
||||||
|
let result = save(content, mime, store_path, "2024-01-01T12-00-00.000-abc123").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result.extension, "txt");
|
||||||
|
assert_eq!(result.byte_size, content.len() as u64);
|
||||||
|
assert!(result.staged_path.exists());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_rejects_unsupported_mime() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let store_path = temp_dir.path();
|
||||||
|
let content = b"test";
|
||||||
|
|
||||||
|
let result = save(content, "text/html", store_path, "2024-01-01T12-00-00.000-abc123");
|
||||||
|
assert!(result.is_err());
|
||||||
|
assert!(result.unwrap_err().to_string().contains("unsupported MIME type"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_save_hash_is_consistent() {
|
||||||
|
let temp_dir = TempDir::new().unwrap();
|
||||||
|
let store_path = temp_dir.path();
|
||||||
|
let content = b"archivr text";
|
||||||
|
|
||||||
|
let result1 = save(content, "text/plain", store_path, "2024-01-01T12-00-00.000-abc123").unwrap();
|
||||||
|
|
||||||
|
let temp_dir2 = TempDir::new().unwrap();
|
||||||
|
let result2 = save(content, "text/plain", temp_dir2.path(), "2024-01-01T12-00-01.000-def456").unwrap();
|
||||||
|
|
||||||
|
assert_eq!(result1.hash, result2.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -275,6 +275,7 @@ pub fn app_with_state(state: AppState) -> Router {
|
||||||
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
|
.route("/api/archives/:archive_id/blobs/:sha256", get(serve_blob))
|
||||||
.route("/api/archives/:archive_id/runs", get(list_runs))
|
.route("/api/archives/:archive_id/runs", get(list_runs))
|
||||||
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
.route("/api/archives/:archive_id/captures", post(capture_handler))
|
||||||
|
.route("/api/archives/:archive_id/captures/text", post(capture_text_handler))
|
||||||
.route(
|
.route(
|
||||||
"/api/archives/:archive_id/uploads",
|
"/api/archives/:archive_id/uploads",
|
||||||
post(upload_handler)
|
post(upload_handler)
|
||||||
|
|
@ -922,6 +923,14 @@ struct CaptureBody {
|
||||||
sync: bool,
|
sync: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize)]
|
||||||
|
struct CaptureTextBody {
|
||||||
|
title: String,
|
||||||
|
body: String,
|
||||||
|
/// MIME type: "text/plain" or "text/markdown" (defaults to "text/markdown")
|
||||||
|
mime: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize)]
|
#[derive(Debug, serde::Deserialize)]
|
||||||
struct ProbeQuery {
|
struct ProbeQuery {
|
||||||
locator: String,
|
locator: String,
|
||||||
|
|
@ -1187,6 +1196,102 @@ async fn capture_handler(
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn capture_text_handler(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
auth_user: AuthUser,
|
||||||
|
Path(archive_id): Path<String>,
|
||||||
|
Json(body): Json<CaptureTextBody>,
|
||||||
|
) -> Result<(StatusCode, Json<serde_json::Value>), ApiError> {
|
||||||
|
auth_user.require_role(ROLE_USER)?;
|
||||||
|
|
||||||
|
// Validate title and body
|
||||||
|
if body.title.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("title must not be empty"));
|
||||||
|
}
|
||||||
|
if body.body.trim().is_empty() {
|
||||||
|
return Err(ApiError::bad_request("body must not be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine MIME type (default to markdown)
|
||||||
|
let mime = body.mime.as_deref().unwrap_or("text/markdown");
|
||||||
|
|
||||||
|
// Validate MIME type
|
||||||
|
if mime != "text/plain" && mime != "text/markdown" {
|
||||||
|
return Err(ApiError::bad_request(
|
||||||
|
"unsupported MIME type: must be 'text/plain' or 'text/markdown'"
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = mounted_archive(&state, &archive_id)?;
|
||||||
|
let archive_paths =
|
||||||
|
archive::read_archive_paths(&mounted.archive_path).map_err(ApiError::from)?;
|
||||||
|
|
||||||
|
// Create job record in the archive DB.
|
||||||
|
let conn = database::open_or_initialize(&mounted.archive_path)?;
|
||||||
|
let job_uid = database::create_capture_job(&conn, &archive_id)?;
|
||||||
|
drop(conn);
|
||||||
|
|
||||||
|
// Spawn background text capture.
|
||||||
|
let title = body.title.trim().to_string();
|
||||||
|
let text_body = body.body.trim().to_string();
|
||||||
|
let mime_str = mime.to_string();
|
||||||
|
let job_uid_bg = job_uid.clone();
|
||||||
|
let archive_path = mounted.archive_path.clone();
|
||||||
|
let archive_id_bg = archive_id.clone();
|
||||||
|
|
||||||
|
tokio::task::spawn_blocking(move || {
|
||||||
|
let conn = match database::open_or_initialize(&archive_path) {
|
||||||
|
Ok(c) => c,
|
||||||
|
Err(e) => {
|
||||||
|
eprintln!("warn: capture job {job_uid_bg}: db open failed: {e:#}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
database::update_capture_job_status(&conn, &job_uid_bg, "running", None, None, None).ok();
|
||||||
|
|
||||||
|
match capture::perform_text_capture(
|
||||||
|
&archive_paths,
|
||||||
|
&title,
|
||||||
|
&text_body,
|
||||||
|
&mime_str,
|
||||||
|
Some(&archive_id_bg),
|
||||||
|
) {
|
||||||
|
Ok(result) => {
|
||||||
|
let job_status = if result.status == "completed" {
|
||||||
|
"completed"
|
||||||
|
} else {
|
||||||
|
"failed"
|
||||||
|
};
|
||||||
|
database::update_capture_job_status(
|
||||||
|
&conn,
|
||||||
|
&job_uid_bg,
|
||||||
|
job_status,
|
||||||
|
Some(&result.run_uid),
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
database::update_capture_job_status(
|
||||||
|
&conn,
|
||||||
|
&job_uid_bg,
|
||||||
|
"failed",
|
||||||
|
None,
|
||||||
|
Some(&format!("{e:#}")),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok((
|
||||||
|
StatusCode::ACCEPTED,
|
||||||
|
Json(serde_json::json!({ "job_uid": job_uid, "status": "pending" })),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
async fn upload_handler(
|
async fn upload_handler(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
auth_user: AuthUser,
|
auth_user: AuthUser,
|
||||||
|
|
@ -3680,6 +3785,143 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_post_returns_accepted_with_job_uid() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"Test content","mime":"text/markdown"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::ACCEPTED);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(
|
||||||
|
json["job_uid"].as_str().is_some(),
|
||||||
|
"response must have job_uid"
|
||||||
|
);
|
||||||
|
assert_eq!(json["status"], "pending");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_empty_title() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"","body":"Test content","mime":"text/plain"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("title must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_empty_body() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"","mime":"text/plain"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("body must not be empty")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_rejects_unsupported_mime() {
|
||||||
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
let (registry, _, auth_path) = make_test_registry(&dir);
|
||||||
|
let session_cookie = make_test_session(&auth_path);
|
||||||
|
let response = app(registry, auth_path)
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.header("cookie", &session_cookie)
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test Note","body":"Test content","mime":"text/html"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
|
||||||
|
let body = axum::body::to_bytes(response.into_body(), usize::MAX)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
let json: serde_json::Value = serde_json::from_slice(&body).unwrap();
|
||||||
|
assert!(json["error"]
|
||||||
|
.as_str()
|
||||||
|
.is_some_and(|e| e.contains("unsupported MIME type")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn text_capture_requires_auth() {
|
||||||
|
let (test_app, _dir) = make_test_app();
|
||||||
|
let response = test_app
|
||||||
|
.oneshot(
|
||||||
|
Request::builder()
|
||||||
|
.method("POST")
|
||||||
|
.uri("/api/archives/test/captures/text")
|
||||||
|
.header("content-type", "application/json")
|
||||||
|
.body(Body::from(
|
||||||
|
r#"{"title":"Test","body":"Content"}"#,
|
||||||
|
))
|
||||||
|
.unwrap(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn probe_requires_auth() {
|
async fn probe_requires_auth() {
|
||||||
let (test_app, _dir) = make_test_app();
|
let (test_app, _dir) = make_test_app();
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
47
crates/archivr-server/static/assets/index-CthL4K20.js
Normal file
47
crates/archivr-server/static/assets/index-CthL4K20.js
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -6,7 +6,7 @@
|
||||||
<title>Archivr</title>
|
<title>Archivr</title>
|
||||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg">
|
||||||
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
<link rel="icon" type="image/x-icon" href="/favicon.ico">
|
||||||
<script type="module" crossorigin src="/assets/index-B67momER.js"></script>
|
<script type="module" crossorigin src="/assets/index-CthL4K20.js"></script>
|
||||||
<link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css">
|
<link rel="stylesheet" crossorigin href="/assets/index-DicH9MNh.css">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,24 @@ export async function submitCapture(archiveId, locator, quality = null, extensio
|
||||||
return res.json(); // { job_uid, status: "pending" }
|
return res.json(); // { job_uid, status: "pending" }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function submitTextCapture(archiveId, {title, body, mime = 'text/markdown'}) {
|
||||||
|
const payload = { title, body };
|
||||||
|
if (mime && mime !== 'text/markdown') payload.mime = mime;
|
||||||
|
|
||||||
|
const res = await fetch(`/api/archives/${archiveId}/captures/text`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
if (!res.ok) {
|
||||||
|
const body = await res.json().catch(() => ({}));
|
||||||
|
const err = new Error(body.error || `HTTP ${res.status}`);
|
||||||
|
err.status = res.status;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
return res.json(); // { job_uid, status: "pending" }
|
||||||
|
}
|
||||||
|
|
||||||
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
|
// Returns { has_video: bool, qualities: string[] } e.g. { has_video: true, qualities: ["1080p","720p","480p"] }
|
||||||
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
|
// Throws on network error; returns { has_video: false, qualities: [] } on non-video locators.
|
||||||
export async function probeCapture(archiveId, locator) {
|
export async function probeCapture(archiveId, locator) {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useRef, useEffect, useState, useCallback } from 'react'
|
import { useRef, useEffect, useState, useCallback } from 'react'
|
||||||
import { submitCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
|
import { submitCapture, submitTextCapture, pollCaptureJob, probeCapture, probePlaylist, getInstanceSettings, uploadFile, deleteUpload } from '../api'
|
||||||
|
|
||||||
let nextItemId = 1
|
let nextItemId = 1
|
||||||
|
|
||||||
|
|
@ -157,6 +157,30 @@ function makeFileItem(filename) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function makeTextItem() {
|
||||||
|
return {
|
||||||
|
id: nextItemId++,
|
||||||
|
kind: 'text',
|
||||||
|
title: '',
|
||||||
|
body: '',
|
||||||
|
mime: 'text/markdown',
|
||||||
|
// Fields present for submission-logic compatibility
|
||||||
|
locator: '',
|
||||||
|
quality: 'best',
|
||||||
|
probeState: 'idle',
|
||||||
|
probeQualities: null,
|
||||||
|
probeHasAudio: false,
|
||||||
|
playlistProbeState: 'idle',
|
||||||
|
playlistInfo: null,
|
||||||
|
playlistItems: null,
|
||||||
|
playlistQuality: null,
|
||||||
|
playlistExpanded: false,
|
||||||
|
syncEnabled: false,
|
||||||
|
error: null,
|
||||||
|
status: 'idle',
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function applyPlaylistQuality(newQ, currentItems) {
|
function applyPlaylistQuality(newQ, currentItems) {
|
||||||
if (newQ === 'best') {
|
if (newQ === 'best') {
|
||||||
return currentItems.map(item => ({ ...item, quality: 'best' }))
|
return currentItems.map(item => ({ ...item, quality: 'best' }))
|
||||||
|
|
@ -430,9 +454,30 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
onToastRef.current(text, null, type, headline)
|
onToastRef.current(text, null, type, headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
async function submitBgJob(locator, quality, batchId, extraExtensions = {}) {
|
async function submitBgJob(submission, batchId) {
|
||||||
const aid = archiveIdRef.current
|
const aid = archiveIdRef.current
|
||||||
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
|
const id = crypto.randomUUID?.() ?? `job-${Date.now()}-${Math.random()}`
|
||||||
|
|
||||||
|
// Text submission
|
||||||
|
if (submission.type === 'text') {
|
||||||
|
try {
|
||||||
|
const job = await submitTextCapture(aid, { title: submission.title, body: submission.body, mime: submission.mime })
|
||||||
|
const locator = `text:${submission.title}`
|
||||||
|
// Notify App to add skeleton + persist
|
||||||
|
onJobStartedRef.current?.({ id, jobUid: job.job_uid, locator, archiveId: aid })
|
||||||
|
startPolling(id, job.job_uid, locator, aid, batchId)
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e.message || 'Submission failed.'
|
||||||
|
onToastRef.current(msg, `text:${submission.title}`)
|
||||||
|
settleBatch(batchId, 'failed', `text:${submission.title}`)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// URL/file submission
|
||||||
|
const locator = submission.locator
|
||||||
|
const quality = submission.quality
|
||||||
|
const extraExtensions = submission.extraExtensions || {}
|
||||||
// Capture session options at call time (synchronous — before first await)
|
// Capture session options at call time (synchronous — before first await)
|
||||||
const extensions = {
|
const extensions = {
|
||||||
ublock_enabled: ublockEnabled,
|
ublock_enabled: ublockEnabled,
|
||||||
|
|
@ -466,16 +511,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
// Guard against the Enter-key shortcut in CaptureRow bypassing the
|
// Guard against the Enter-key shortcut in CaptureRow bypassing the
|
||||||
// disabled button — uploads must be complete before archiving starts.
|
// disabled button — uploads must be complete before archiving starts.
|
||||||
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
|
if (items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')) return
|
||||||
const toSubmit = items.filter(it =>
|
const toSubmit = items.filter(it => {
|
||||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
if (it.kind === 'file') return it.uploadStatus === 'done' && it.uploadLocator
|
||||||
)
|
if (it.kind === 'text') return it.title.trim() && it.body.trim()
|
||||||
|
return it.locator.trim()
|
||||||
|
})
|
||||||
if (toSubmit.length === 0) return
|
if (toSubmit.length === 0) return
|
||||||
if (toSubmit.some(it => it.kind !== 'file' && hasConflict(it))) return
|
if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && hasConflict(it))) return
|
||||||
if (toSubmit.some(it => it.kind !== 'file' && (
|
if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && (
|
||||||
it.probeState === 'probing' ||
|
it.probeState === 'probing' ||
|
||||||
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
|
(isPlaylistSource(it.locator) && it.playlistProbeState !== 'done'))))
|
||||||
return
|
return
|
||||||
if (toSubmit.some(it => it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
if (toSubmit.some(it => it.kind !== 'file' && it.kind !== 'text' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0)) return
|
||||||
const batchId = toSubmit.length > 1
|
const batchId = toSubmit.length > 1
|
||||||
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
? (crypto.randomUUID?.() ?? `batch-${Date.now()}`)
|
||||||
: null
|
: null
|
||||||
|
|
@ -485,9 +532,13 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
// Capture all submission data before any state changes
|
// Capture all submission data before any state changes
|
||||||
const submissions = toSubmit.map(it => {
|
const submissions = toSubmit.map(it => {
|
||||||
if (it.kind === 'file') {
|
if (it.kind === 'file') {
|
||||||
return { locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
|
return { type: 'file', locator: it.uploadLocator, quality: 'best', extraExtensions: {} }
|
||||||
|
}
|
||||||
|
if (it.kind === 'text') {
|
||||||
|
return { type: 'text', title: it.title.trim(), body: it.body.trim(), mime: it.mime }
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
|
type: 'url',
|
||||||
locator: it.locator.trim(),
|
locator: it.locator.trim(),
|
||||||
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
quality: it.playlistItems !== null ? null : (it.quality || 'best'),
|
||||||
extraExtensions: it.playlistItems !== null
|
extraExtensions: it.playlistItems !== null
|
||||||
|
|
@ -502,8 +553,8 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
setItems([makeItem()])
|
setItems([makeItem()])
|
||||||
dialogRef.current?.close()
|
dialogRef.current?.close()
|
||||||
// Submit each in background
|
// Submit each in background
|
||||||
submissions.forEach(({ locator, quality, extraExtensions }) =>
|
submissions.forEach(submission =>
|
||||||
submitBgJob(locator, quality, batchId, extraExtensions)
|
submitBgJob(submission, batchId)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -687,16 +738,18 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
|
|
||||||
|
|
||||||
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
|
const anyUploading = items.some(it => it.kind === 'file' && it.uploadStatus === 'uploading')
|
||||||
const pendingCount = items.filter(it =>
|
const pendingCount = items.filter(it => {
|
||||||
it.kind === 'file' ? (it.uploadStatus === 'done' && it.uploadLocator) : it.locator.trim()
|
if (it.kind === 'file') return it.uploadStatus === 'done' && it.uploadLocator
|
||||||
).length
|
if (it.kind === 'text') return it.title.trim() && it.body.trim()
|
||||||
const anyConflict = items.some(it => it.kind !== 'file' && hasConflict(it))
|
return it.locator.trim()
|
||||||
|
}).length
|
||||||
|
const anyConflict = items.some(it => it.kind !== 'file' && it.kind !== 'text' && hasConflict(it))
|
||||||
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
// True if any playlist row has had all its videos deleted — archive would be a no-op.
|
||||||
const anyEmptyPlaylist = items.some(it =>
|
const anyEmptyPlaylist = items.some(it =>
|
||||||
it.kind !== 'file' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
it.kind !== 'file' && it.kind !== 'text' && Array.isArray(it.playlistItems) && it.playlistItems.length === 0
|
||||||
)
|
)
|
||||||
const anyProbing = items.some(it =>
|
const anyProbing = items.some(it =>
|
||||||
it.kind !== 'file' && (
|
it.kind !== 'file' && it.kind !== 'text' && (
|
||||||
it.probeState === 'probing' ||
|
it.probeState === 'probing' ||
|
||||||
// For playlist sources block unless probe completed successfully:
|
// For playlist sources block unless probe completed successfully:
|
||||||
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
// idle = debounce not yet fired; probing = in flight; error = no quality data.
|
||||||
|
|
@ -735,6 +788,17 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
item={item}
|
item={item}
|
||||||
onRemove={() => removeRow(item.id)}
|
onRemove={() => removeRow(item.id)}
|
||||||
/>
|
/>
|
||||||
|
) : item.kind === 'text' ? (
|
||||||
|
<CaptureTextRow
|
||||||
|
key={item.id}
|
||||||
|
item={item}
|
||||||
|
autoFocus={idx === items.length - 1}
|
||||||
|
onTitleChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, title: val } : it))}
|
||||||
|
onBodyChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, body: val } : it))}
|
||||||
|
onMimeChange={val => setItems(prev => prev.map(it => it.id === item.id ? { ...it, mime: val } : it))}
|
||||||
|
onRemove={() => removeRow(item.id)}
|
||||||
|
onSubmit={handleArchive}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CaptureRow
|
<CaptureRow
|
||||||
key={item.id}
|
key={item.id}
|
||||||
|
|
@ -781,6 +845,12 @@ export default function CaptureDialog({ open, archiveId, onClose, onCaptured, on
|
||||||
</svg>
|
</svg>
|
||||||
Upload file
|
Upload file
|
||||||
</button>
|
</button>
|
||||||
|
<button type="button" className="capture-add-row capture-add-text" onClick={() => setItems(prev => [...prev, makeTextItem()])}>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round">
|
||||||
|
<path d="M2 3h12M2 7h12M2 11h8"/>
|
||||||
|
</svg>
|
||||||
|
Add text
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Advanced options ────────────────────────────── */}
|
{/* ── Advanced options ────────────────────────────── */}
|
||||||
|
|
@ -1139,3 +1209,63 @@ function CaptureFileRow({ item, onRemove }) {
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function CaptureTextRow({ item, autoFocus, onTitleChange, onBodyChange, onMimeChange, onRemove, onSubmit }) {
|
||||||
|
const titleInputRef = useRef(null)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (autoFocus) {
|
||||||
|
titleInputRef.current?.focus()
|
||||||
|
}
|
||||||
|
}, [autoFocus]) // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="capture-row capture-text-row">
|
||||||
|
<div className="capture-row-main">
|
||||||
|
<span className="capture-text-icon" aria-hidden="true">
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round" strokeLinejoin="round" style={{ width: 14, height: 14 }}>
|
||||||
|
<path d="M2 3h12M2 7h12M2 11h8"/>
|
||||||
|
</svg>
|
||||||
|
</span>
|
||||||
|
<div className="capture-text-inputs">
|
||||||
|
<input
|
||||||
|
ref={titleInputRef}
|
||||||
|
className="capture-text-input capture-text-title"
|
||||||
|
type="text"
|
||||||
|
placeholder="Title"
|
||||||
|
value={item.title}
|
||||||
|
onChange={e => onTitleChange(e.target.value)}
|
||||||
|
maxLength={500}
|
||||||
|
/>
|
||||||
|
<textarea
|
||||||
|
className="capture-text-input capture-text-body"
|
||||||
|
placeholder="Body (markdown or plain text)"
|
||||||
|
value={item.body}
|
||||||
|
onChange={e => onBodyChange(e.target.value)}
|
||||||
|
rows={6}
|
||||||
|
/>
|
||||||
|
<div className="capture-text-footer">
|
||||||
|
<select
|
||||||
|
className="capture-text-mime"
|
||||||
|
value={item.mime}
|
||||||
|
onChange={e => onMimeChange(e.target.value)}
|
||||||
|
>
|
||||||
|
<option value="text/markdown">Markdown</option>
|
||||||
|
<option value="text/plain">Plain text</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="capture-row-action capture-row-remove"
|
||||||
|
onClick={onRemove}
|
||||||
|
aria-label="Remove"
|
||||||
|
>
|
||||||
|
<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round">
|
||||||
|
<line x1="3" y1="3" x2="13" y2="13"/><line x1="13" y1="3" x2="3" y2="13"/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue