Remove no longer necessary functions

This commit is contained in:
Andras Schmelczer 2026-09-19 15:25:49 +01:00
commit 9f84dce6c3
13 changed files with 0 additions and 1063 deletions

View file

@ -1,41 +0,0 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{StoredDocumentVersion, VaultId, VaultUpdateId};
use super::{Database, Transaction};
impl Database {
pub async fn get_document_version(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from documents
where vault_update_id = ?"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch document version")
}
}

View file

@ -1,43 +0,0 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentId, StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
pub async fn get_latest_document(
&self,
vault: &VaultId,
document_id: &DocumentId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let document_id = document_id.as_hyphenated();
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from latest_document_versions
where document_id = ?
"#,
document_id
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
}

View file

@ -1,46 +0,0 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
pub async fn get_latest_document_by_path(
&self,
vault: &VaultId,
relative_path: &str,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Option<StoredDocumentVersion>> {
let query = sqlx::query_as!(
StoredDocumentVersion,
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
content,
is_deleted,
user_id,
device_id
from latest_document_versions
where relative_path = ? and is_deleted = false
order by vault_update_id desc -- `latest_document_versions` only contains a single latest version of each document, however,
-- multiple documents can have the same `relative_path`, if they have been deleted. That's
-- why we only care about the latest version of the document with the given relative path.
limit 1
"#,
relative_path
);
if let Some(transaction) = transaction {
query.fetch_optional(&mut **transaction).await
} else {
query
.fetch_optional(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest document version")
}
}

View file

@ -1,56 +0,0 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentVersionWithoutContent, VaultId};
use super::{Database, Transaction};
impl Database {
/// Return the latest state of all documents in the vault
pub async fn get_latest_documents(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
order by vault_update_id
"#,
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.context("Cannot fetch latest documents")
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
}

View file

@ -1,62 +0,0 @@
use anyhow::{Context as _, Result};
use sqlx::types::chrono::Utc;
use uuid::fmt::Hyphenated;
use super::models::{DocumentVersionWithoutContent, VaultId, VaultUpdateId};
use super::{Database, Transaction};
impl Database {
/// Return the latest state of all documents (including deleted) in the
/// vault which have changed since the given update id
pub async fn get_latest_documents_since(
&self,
vault: &VaultId,
vault_update_id: VaultUpdateId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<Vec<DocumentVersionWithoutContent>> {
let query = sqlx::query!(
r#"
select
vault_update_id,
document_id as "document_id: Hyphenated",
relative_path,
updated_date as "updated_date: chrono::DateTime<Utc>",
is_deleted,
user_id,
device_id,
length(content) as "content_size: u64"
from latest_document_versions
where vault_update_id > ?
order by vault_update_id
"#,
vault_update_id
);
if let Some(transaction) = transaction {
query.fetch_all(&mut **transaction).await
} else {
query
.fetch_all(&self.get_connection_pool(vault).await?)
.await
}
.with_context(|| {
format!("Cannot fetch latest documents since vault_update_id `{vault_update_id}`")
})
.map(|rows| {
rows.into_iter()
.map(|row| DocumentVersionWithoutContent {
vault_update_id: row.vault_update_id,
document_id: row.document_id.into(),
relative_path: row.relative_path,
updated_date: row.updated_date,
is_deleted: row.is_deleted,
user_id: row.user_id,
device_id: row.device_id,
content_size: row
.content_size
.expect("Content size can't be null but sqlx can't infer it"),
})
.collect()
})
}
}

View file

@ -1,29 +0,0 @@
use anyhow::{Context as _, Result};
use super::models::VaultId;
use super::{Database, Transaction};
impl Database {
pub async fn get_max_update_id_in_vault(
&self,
vault: &VaultId,
transaction: Option<&mut Transaction<'_>>,
) -> Result<i64> {
let query = sqlx::query!(
r#"
select coalesce(max(vault_update_id), 0) as max_vault_update_id
from documents
"#,
);
if let Some(transaction) = transaction {
query.fetch_one(&mut **transaction).await
} else {
query
.fetch_one(&self.get_connection_pool(vault).await?)
.await
}
.map(|row| row.max_vault_update_id)
.context("Cannot fetch max update id in vault")
}
}

View file

@ -1,38 +0,0 @@
use anyhow::{Context as _, Result};
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
impl Database {
/// Look up the immutable version acknowledged by a previous push.
pub async fn get_push_acknowledgement(
&self,
vault_id: &VaultId,
request_id: uuid::Uuid,
transaction: &mut Transaction<'_>,
) -> Result<Option<(Vec<u8>, StoredDocumentVersion)>> {
let request_id = request_id.hyphenated().to_string();
let acknowledgement = sqlx::query!(
"select vault_update_id, request_fingerprint from push_acknowledgements where request_id = ?",
request_id
)
.fetch_optional(&mut **transaction)
.await
.context("Cannot fetch push acknowledgement")?;
match acknowledgement {
Some(acknowledgement) => {
let version = self
.get_document_version(
vault_id,
acknowledgement.vault_update_id,
Some(transaction),
)
.await?
.context("Acknowledged document version is missing")?;
Ok(Some((acknowledgement.request_fingerprint, version)))
}
None => Ok(None),
}
}
}

View file

@ -1,79 +0,0 @@
use anyhow::{Context as _, Result};
use super::models::{StoredDocumentVersion, VaultId};
use super::{Database, Transaction};
use crate::app_state::websocket::models::{
WebSocketServerMessage, WebSocketServerMessageWithOrigin, WebSocketVaultUpdate,
};
impl Database {
/// Commit the snapshot and acknowledgement atomically, then notify subscribers.
pub async fn insert_document_version(
&self,
vault_id: &VaultId,
version: &StoredDocumentVersion,
request_id: uuid::Uuid,
request_fingerprint: &[u8],
mut transaction: Transaction<'_>,
) -> Result<()> {
let document_id = version.document_id.as_hyphenated();
sqlx::query!(
r#"
insert into documents (
vault_update_id,
document_id,
relative_path,
updated_date,
content,
is_deleted,
user_id,
device_id
)
values (?, ?, ?, ?, ?, ?, ?, ?)
"#,
version.vault_update_id,
document_id,
version.relative_path,
version.updated_date,
version.content,
version.is_deleted,
version.user_id,
version.device_id
)
.execute(&mut *transaction)
.await
.context("Cannot insert document version")?;
let request_id = request_id.hyphenated().to_string();
sqlx::query!(
"insert into push_acknowledgements (request_id, request_fingerprint, vault_update_id) values (?, ?, ?)",
request_id,
request_fingerprint,
version.vault_update_id
)
.execute(&mut *transaction)
.await
.context("Cannot save push acknowledgement")?;
transaction
.commit()
.await
.context("Failed to commit push")?;
// notifying the listeners here means that no update can circumvent this step
self.broadcasts
.send_document_update(
vault_id.clone(),
WebSocketServerMessageWithOrigin::with_origin(
version.device_id.clone(),
WebSocketServerMessage::VaultUpdate(WebSocketVaultUpdate {
documents: vec![version.clone().into()],
is_initial_sync: false,
}),
),
)
.await;
Ok(())
}
}

View file

@ -1,36 +0,0 @@
use axum::{
Json,
extract::{Path, State},
};
use log::debug;
use serde::Deserialize;
use crate::{
app_state::{
AppState,
database::models::{DocumentVersionWithoutContent, VaultId},
},
errors::{SyncServerError, server_error},
utils::normalize::normalize,
};
#[derive(Deserialize)]
pub struct FetchLatestDocumentsPathParams {
#[serde(deserialize_with = "normalize")]
vault_id: VaultId,
}
#[axum::debug_handler]
pub async fn fetch_latest_documents(
Path(FetchLatestDocumentsPathParams { vault_id }): Path<FetchLatestDocumentsPathParams>,
State(state): State<AppState>,
) -> Result<Json<Vec<DocumentVersionWithoutContent>>, SyncServerError> {
debug!("Fetching latest documents in {vault_id}");
state
.database
.get_latest_documents(&vault_id, None)
.await
.map(Json)
.map_err(server_error)
}

View file

@ -1,423 +0,0 @@
use std::path::PathBuf;
use super::*;
use crate::{
app_state::{
database::models::{DocumentVersionWithoutContent, VaultUpdateId},
websocket::broadcasts::Broadcasts,
},
config::{database_config::DatabaseConfig, server_config::ServerConfig},
};
struct Push {
document_id: DocumentId,
request_id: uuid::Uuid,
parent_version_id: Option<VaultUpdateId>,
relative_path: String,
content: PushContent,
user_id: String,
device_id: String,
}
impl Push {
async fn send(
self,
database: &Database,
vault: &VaultId,
) -> Result<DocumentUpdateResponse, SyncServerError> {
accept_push(
database,
vault,
self.document_id,
self.user_id,
self.device_id,
PushDocument {
request_id: self.request_id,
parent_version_id: self.parent_version_id,
relative_path: self.relative_path,
content: self.content,
},
)
.await
}
}
struct TestVault {
directory: PathBuf,
database: Database,
vault: VaultId,
}
impl TestVault {
async fn new() -> Self {
let directory =
std::env::temp_dir().join(format!("vaultlink-cas-{}", uuid::Uuid::new_v4()));
let database = Self::open(&directory).await;
Self {
directory,
database,
vault: "test".to_owned(),
}
}
async fn open(directory: &std::path::Path) -> Database {
Database::try_new(
&DatabaseConfig {
databases_directory_path: directory.to_owned(),
..Default::default()
},
&Broadcasts::new(&ServerConfig {
max_clients_per_vault: 16,
..Default::default()
}),
)
.await
.unwrap()
}
async fn send(&self, push: Push) -> DocumentUpdateResponse {
push.send(&self.database, &self.vault).await.unwrap()
}
async fn latest(&self, document: DocumentId) -> StoredDocumentVersion {
self.database
.get_latest_document(&self.vault, &document, None)
.await
.unwrap()
.unwrap()
}
}
impl Drop for TestVault {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.directory);
}
}
fn snapshot(document_id: DocumentId, parent: Option<VaultUpdateId>, text: &str) -> Push {
Push {
document_id,
request_id: uuid::Uuid::new_v4(),
parent_version_id: parent,
relative_path: "note.md".to_owned(),
content: PushContent::Snapshot(STANDARD.encode(text)),
user_id: "user".to_owned(),
device_id: "device".to_owned(),
}
}
fn accepted(response: DocumentUpdateResponse) -> DocumentVersionWithoutContent {
match response {
DocumentUpdateResponse::Accepted(version) => version,
response @ DocumentUpdateResponse::StaleBase(_) => {
panic!("Expected Accepted, got {response:?}")
}
}
}
#[tokio::test]
async fn json_snapshot_preserves_bytes_and_rejects_invalid_base64_without_a_version() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let content = [0, 255, 13, 10, 42];
let request: PushDocument = serde_json::from_value(serde_json::json!({
"requestId": uuid::Uuid::new_v4(),
"parentVersionId": null,
"relativePath": "snapshot.bin",
"content": { "type": "Snapshot", "value": STANDARD.encode(content) },
}))
.unwrap();
let base = accepted(
accept_push(
&vault.database,
&vault.vault,
document,
"user".to_owned(),
"device".to_owned(),
request,
)
.await
.unwrap(),
);
assert_eq!(vault.latest(document).await.content, content);
let invalid: PushDocument = serde_json::from_value(serde_json::json!({
"requestId": uuid::Uuid::new_v4(),
"parentVersionId": base.vault_update_id,
"relativePath": "snapshot.bin",
"content": { "type": "Snapshot", "value": "not base64!" },
}))
.unwrap();
assert!(matches!(
accept_push(
&vault.database,
&vault.vault,
document,
"user".to_owned(),
"device".to_owned(),
invalid
)
.await,
Err(SyncServerError::ClientError(_))
));
assert_eq!(
vault.latest(document).await.vault_update_id,
base.vault_update_id
);
}
#[tokio::test]
async fn concurrent_writers_only_accept_one_snapshot_and_preserve_the_base() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let base = accepted(vault.send(snapshot(document, None, "base")).await);
let (left, right) = tokio::join!(
vault.send(snapshot(document, Some(base.vault_update_id), "left")),
vault.send(snapshot(document, Some(base.vault_update_id), "right")),
);
let (winner, rejected) = match (left, right) {
(DocumentUpdateResponse::Accepted(winner), DocumentUpdateResponse::StaleBase(rejected))
| (DocumentUpdateResponse::StaleBase(rejected), DocumentUpdateResponse::Accepted(winner)) => {
(winner, rejected)
}
outcomes => panic!("Expected one accepted push and one stale base: {outcomes:?}"),
};
let latest = vault.latest(document).await;
assert_eq!(winner.vault_update_id, rejected.vault_update_id);
assert_eq!(
STANDARD.decode(rejected.content_base64).unwrap(),
latest.content
);
assert!(latest.content == b"left" || latest.content == b"right");
let original = vault
.database
.get_document_version(&vault.vault, base.vault_update_id, None)
.await
.unwrap()
.unwrap();
assert_eq!(original.content, b"base");
assert_eq!(
vault
.database
.get_max_update_id_in_vault(&vault.vault, None)
.await
.unwrap(),
2
);
}
#[tokio::test]
async fn acknowledgements_survive_reopening_and_return_the_original_version() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let create = snapshot(document, None, "created");
let create_id = create.request_id;
let base = accepted(vault.send(create).await);
let update = snapshot(document, Some(base.vault_update_id), "updated");
let update_id = update.request_id;
let updated = accepted(vault.send(update).await);
let latest = accepted(
vault
.send(snapshot(document, Some(updated.vault_update_id), "newest"))
.await,
);
let reopened = TestVault::open(&vault.directory).await;
for (request_id, parent, text, original) in [
(create_id, None, "created", base),
(update_id, Some(1), "updated", updated),
] {
let mut retry = snapshot(document, parent, text);
retry.request_id = request_id;
retry.device_id = "new-session-after-restart".to_owned();
let acknowledged = accepted(retry.send(&reopened, &vault.vault).await.unwrap());
assert_eq!(
serde_json::to_value(acknowledged).unwrap(),
serde_json::to_value(original).unwrap()
);
}
assert_eq!(
vault.latest(document).await.vault_update_id,
latest.vault_update_id
);
let mut reused = snapshot(uuid::Uuid::new_v4(), None, "wrong document");
reused.request_id = create_id;
assert!(matches!(
reused.send(&reopened, &vault.vault).await,
Err(SyncServerError::ClientError(_))
));
let mut changed_path = snapshot(document, None, "created");
changed_path.relative_path = "other.md".to_owned();
let mut changed_operation = snapshot(document, None, "created");
changed_operation.content = PushContent::Delete;
for mut changed_payload in [
snapshot(document, None, "different content"),
snapshot(document, Some(1), "created"),
changed_path,
changed_operation,
] {
changed_payload.request_id = create_id;
assert!(matches!(
changed_payload.send(&reopened, &vault.vault).await,
Err(SyncServerError::ClientError(_))
));
}
}
#[tokio::test]
async fn deletes_compare_the_base_and_replay_without_resurrecting_documents() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let base = accepted(vault.send(snapshot(document, None, "base")).await);
let updated = accepted(
vault
.send(snapshot(document, Some(base.vault_update_id), "edited"))
.await,
);
let mut delete = snapshot(document, Some(base.vault_update_id), "");
delete.content = PushContent::Delete;
assert!(matches!(
vault.send(delete).await,
DocumentUpdateResponse::StaleBase(_)
));
assert!(!vault.latest(document).await.is_deleted);
let mut delete = snapshot(document, Some(updated.vault_update_id), "");
delete.content = PushContent::Delete;
let request_id = delete.request_id;
let deleted = accepted(vault.send(delete).await);
assert!(deleted.is_deleted);
assert_eq!(vault.latest(document).await.content, b"edited");
let mut retry = snapshot(document, Some(updated.vault_update_id), "");
retry.content = PushContent::Delete;
retry.request_id = request_id;
assert_eq!(
accepted(vault.send(retry).await).vault_update_id,
deleted.vault_update_id
);
let terminal = vault
.send(snapshot(
document,
Some(deleted.vault_update_id),
"resurrect",
))
.await;
assert!(matches!(terminal, DocumentUpdateResponse::StaleBase(version) if version.is_deleted));
let recreated = accepted(
vault
.send(snapshot(uuid::Uuid::new_v4(), None, "new identity"))
.await,
);
assert_eq!(recreated.relative_path, "note.md");
}
#[tokio::test]
async fn deleting_a_missing_document_without_a_parent_is_a_client_error() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let mut delete = snapshot(document, None, "");
delete.content = PushContent::Delete;
assert!(matches!(
delete.send(&vault.database, &vault.vault).await,
Err(SyncServerError::ClientError(_))
));
assert_eq!(
vault
.database
.get_latest_document(&vault.vault, &document, None)
.await
.unwrap(),
None
);
}
#[tokio::test]
async fn transport_diffs_reconstruct_exact_snapshots_and_reject_invalid_bases() {
let vault = TestVault::new().await;
let document = uuid::Uuid::new_v4();
let original = "Hello world 🌍";
let desired = "Hi beautiful world 🌍";
let base = accepted(vault.send(snapshot(document, None, original)).await);
let mut update = snapshot(document, Some(base.vault_update_id), "");
update.content =
PushContent::Diff(EditedText::from_strings(original, &desired.into()).to_diff());
let updated = accepted(vault.send(update).await);
assert_eq!(vault.latest(document).await.content, desired.as_bytes());
for invalid_length in [i64::MIN, i64::MAX] {
let mut malformed = snapshot(document, Some(updated.vault_update_id), "");
malformed.content = PushContent::Diff(vec![NumberOrText::Number(invalid_length)]);
assert!(matches!(
malformed.send(&vault.database, &vault.vault).await,
Err(SyncServerError::ClientError(_))
));
}
assert_eq!(
vault.latest(document).await.vault_update_id,
updated.vault_update_id
);
let another = accepted(
vault
.send(snapshot(uuid::Uuid::new_v4(), None, "another document"))
.await,
);
let mut foreign_base = snapshot(document, Some(another.vault_update_id), "");
foreign_base.content = PushContent::Diff(vec![]);
assert!(
matches!(vault.send(foreign_base).await, DocumentUpdateResponse::StaleBase(version) if version.vault_update_id == updated.vault_update_id)
);
let binary_document = uuid::Uuid::new_v4();
let mut binary = snapshot(binary_document, None, "");
binary.content = PushContent::Snapshot(STANDARD.encode([0xff]));
let binary_base = accepted(vault.send(binary).await);
let mut invalid_text = snapshot(binary_document, Some(binary_base.vault_update_id), "");
invalid_text.content = PushContent::Diff(vec![]);
assert!(matches!(
invalid_text.send(&vault.database, &vault.vault).await,
Err(SyncServerError::ClientError(_))
));
assert_eq!(
vault.latest(binary_document).await.vault_update_id,
binary_base.vault_update_id
);
}
#[tokio::test]
async fn renames_allocate_paths_atomically_without_rewriting_history() {
let vault = TestVault::new().await;
let first = uuid::Uuid::new_v4();
let second = uuid::Uuid::new_v4();
let base = accepted(vault.send(snapshot(first, None, "first")).await);
let collision = accepted(vault.send(snapshot(second, None, "second")).await);
assert_ne!(base.relative_path, collision.relative_path);
let mut rename = snapshot(first, Some(base.vault_update_id), "first");
rename.relative_path = "renamed.md".to_owned();
let renamed = accepted(vault.send(rename).await);
let mut stale_rename = snapshot(first, Some(base.vault_update_id), "first");
stale_rename.relative_path = "stale.md".to_owned();
assert!(
matches!(vault.send(stale_rename).await, DocumentUpdateResponse::StaleBase(version) if version.relative_path == renamed.relative_path)
);
let mut move_into_occupied = snapshot(second, Some(collision.vault_update_id), "second");
move_into_occupied.relative_path = renamed.relative_path.clone();
let allocated = accepted(vault.send(move_into_occupied).await);
assert_ne!(allocated.relative_path, renamed.relative_path);
assert_eq!(vault.latest(first).await.relative_path, "renamed.md");
assert_eq!(
vault
.database
.get_document_version(&vault.vault, base.vault_update_id, None)
.await
.unwrap()
.unwrap()
.relative_path,
"note.md"
);
}

View file

@ -1,150 +0,0 @@
use regex::Regex;
pub fn dedup_paths(path: &str) -> impl Iterator<Item = String> {
let mut path_parts = path.split('/').collect::<Vec<_>>();
let file_name = path_parts.pop().unwrap().to_owned();
let mut directory = path_parts.join("/");
if !directory.is_empty() {
directory.push('/');
}
// Handle dotfiles: ".gitignore" should have no extension, ".config.json" should split as ".config" + ".json"
let is_simple_dotfile = file_name.starts_with('.') && file_name.matches('.').count() == 1;
let (stem, extension) = if is_simple_dotfile {
(file_name.clone(), String::new())
} else {
// Regular file or dotfile with extension
let name_parts = file_name.rsplitn(2, '.').collect::<Vec<_>>();
let mut reverse_parts = name_parts.into_iter().rev();
match (reverse_parts.next(), reverse_parts.next()) {
(Some(stem), maybe_extension) => (
stem.to_owned(),
maybe_extension
.map(|ext| format!(".{ext}"))
.unwrap_or_default(),
),
_ => unreachable!("Path must have at least one part"),
}
};
let regex = Regex::new(r" \((\d+)\)$").unwrap();
let start_number = regex
.captures(&stem)
.and_then(|caps| caps.get(1))
.and_then(|m| m.as_str().parse::<u32>().ok())
.unwrap_or(0);
let clean_stem = regex.replace(&stem, "").to_string();
(start_number..).map(move |dedup_number| {
if dedup_number == 0 {
format!("{directory}{clean_stem}{extension}")
} else {
format!("{directory}{clean_stem} ({dedup_number}){extension}")
}
})
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_dedup_paths() {
let mut deduped = dedup_paths("file.txt");
assert_eq!(deduped.next(), Some("file.txt".to_owned()));
assert_eq!(deduped.next(), Some("file (1).txt".to_owned()));
assert_eq!(deduped.next(), Some("file (2).txt".to_owned()));
let mut deduped = dedup_paths("file");
assert_eq!(deduped.next(), Some("file".to_owned()));
assert_eq!(deduped.next(), Some("file (1)".to_owned()));
assert_eq!(deduped.next(), Some("file (2)".to_owned()));
let mut deduped = dedup_paths("file (51).md");
assert_eq!(deduped.next(), Some("file (51).md".to_owned()));
assert_eq!(deduped.next(), Some("file (52).md".to_owned()));
assert_eq!(deduped.next(), Some("file (53).md".to_owned()));
let mut deduped = dedup_paths("file (5)");
assert_eq!(deduped.next(), Some("file (5)".to_owned()));
assert_eq!(deduped.next(), Some("file (6)".to_owned()));
assert_eq!(deduped.next(), Some("file (7)".to_owned()));
let mut deduped = dedup_paths("my/path.with.dots/file (5).md");
assert_eq!(
deduped.next(),
Some("my/path.with.dots/file (5).md".to_owned())
);
assert_eq!(
deduped.next(),
Some("my/path.with.dots/file (6).md".to_owned())
);
let mut deduped = dedup_paths("my/path.with.dots/file (5)");
assert_eq!(
deduped.next(),
Some("my/path.with.dots/file (5)".to_owned())
);
assert_eq!(
deduped.next(),
Some("my/path.with.dots/file (6)".to_owned())
);
}
#[test]
fn test_regex_capturing_group() {
// Single digit in parentheses
let mut deduped = dedup_paths("document (5).md");
assert_eq!(deduped.next(), Some("document (5).md".to_owned()));
assert_eq!(deduped.next(), Some("document (6).md".to_owned()));
assert_eq!(deduped.next(), Some("document (7).md".to_owned()));
// Multi-digit number
let mut deduped = dedup_paths("report (123).pdf");
assert_eq!(deduped.next(), Some("report (123).pdf".to_owned()));
assert_eq!(deduped.next(), Some("report (124).pdf".to_owned()));
assert_eq!(deduped.next(), Some("report (125).pdf".to_owned()));
// Number without extension
let mut deduped = dedup_paths("folder (99)");
assert_eq!(deduped.next(), Some("folder (99)".to_owned()));
assert_eq!(deduped.next(), Some("folder (100)".to_owned()));
assert_eq!(deduped.next(), Some("folder (101)".to_owned()));
}
#[test]
fn test_dedup_dotfiles() {
// Simple dotfile (no extension)
let mut deduped = dedup_paths(".gitignore");
assert_eq!(deduped.next(), Some(".gitignore".to_owned()));
assert_eq!(deduped.next(), Some(".gitignore (1)".to_owned()));
assert_eq!(deduped.next(), Some(".gitignore (2)".to_owned()));
// Dotfile with extension
let mut deduped = dedup_paths(".config.json");
assert_eq!(deduped.next(), Some(".config.json".to_owned()));
assert_eq!(deduped.next(), Some(".config (1).json".to_owned()));
assert_eq!(deduped.next(), Some(".config (2).json".to_owned()));
// Dotfile with number
let mut deduped = dedup_paths(".gitignore (5)");
assert_eq!(deduped.next(), Some(".gitignore (5)".to_owned()));
assert_eq!(deduped.next(), Some(".gitignore (6)".to_owned()));
assert_eq!(deduped.next(), Some(".gitignore (7)".to_owned()));
// Dotfile with extension and number
let mut deduped = dedup_paths(".config (3).json");
assert_eq!(deduped.next(), Some(".config (3).json".to_owned()));
assert_eq!(deduped.next(), Some(".config (4).json".to_owned()));
assert_eq!(deduped.next(), Some(".config (5).json".to_owned()));
// Dotfile in subdirectory
let mut deduped = dedup_paths("my/path/.gitignore");
assert_eq!(deduped.next(), Some("my/path/.gitignore".to_owned()));
assert_eq!(deduped.next(), Some("my/path/.gitignore (1)".to_owned()));
assert_eq!(deduped.next(), Some("my/path/.gitignore (2)".to_owned()));
}
}

View file

@ -1,26 +0,0 @@
use crate::app_state::database::models::VaultId;
use crate::{app_state::database::Transaction, utils::dedup_paths::dedup_paths};
use anyhow::Result;
use log::{debug, info};
pub async fn find_first_available_path(
vault_id: &VaultId,
sanitized_relative_path: &str,
database: &crate::app_state::database::Database,
transaction: &mut Transaction<'_>,
) -> Result<String> {
info!("Finding first available path for `{sanitized_relative_path}` in vault `{vault_id}`");
for candidate in dedup_paths(sanitized_relative_path) {
debug!("Checking candidate path for deconflicting names: `{candidate}`");
if database
.get_latest_document_by_path(vault_id, &candidate, Some(transaction))
.await?
.is_none()
{
info!("Selected available path: `{candidate}`");
return Ok(candidate);
}
}
unreachable!("dedup_paths produces infinite paths");
}

View file

@ -1,34 +0,0 @@
/// Sanitize the document's path to allow all clients to create the same path in
/// their filesystem. If we didn't do this server-side, client's would need to
/// deal with mapping invalid names to valid ones and then back.
pub fn sanitize_path(path: &str) -> String {
let options = sanitize_filename::Options {
truncate: true,
windows: true, // Windows is the lowest common denominator
replacement: "",
};
path.split('/')
.map(|part| {
let proposal = sanitize_filename::sanitize_with_options(part, options.clone());
if !part.is_empty() && proposal.is_empty() {
"_".to_owned()
} else {
proposal
}
})
.collect::<Vec<_>>()
.join("/")
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn test_sanitize_path() {
assert_eq!(sanitize_path("/my/path/what?"), "/my/path/what");
assert_eq!(sanitize_path("file (1).md"), "file (1).md");
assert_eq!(sanitize_path("/my/path/\\\\:?"), "/my/path/_");
}
}