Add cloud browsing, autosave, and editor improvements
This commit is contained in:
@@ -34,7 +34,7 @@ pub fn assets_dir(app: &AppHandle, store: &Store) -> Result<PathBuf, String> {
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
fn families_in(data: &[u8]) -> Vec<String> {
|
||||
pub fn families_in(data: &[u8]) -> Vec<String> {
|
||||
let mut families = BTreeSet::new();
|
||||
for font in typst::text::Font::iter(typst::foundations::Bytes::new(data.to_vec())) {
|
||||
families.insert(font.info().family.clone());
|
||||
|
||||
+67
-1
@@ -1,4 +1,5 @@
|
||||
use rusqlite::{params, Connection, OptionalExtension};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Mutex;
|
||||
use tauri::{AppHandle, Manager};
|
||||
@@ -9,7 +10,15 @@ pub struct Store {
|
||||
connection: Mutex<Connection>,
|
||||
}
|
||||
|
||||
const SCHEMA: [&str; 4] = [
|
||||
#[derive(Serialize, Clone)]
|
||||
pub struct DocumentLink {
|
||||
pub document_id: String,
|
||||
pub base_hash: String,
|
||||
pub role: String,
|
||||
pub base_content: String,
|
||||
}
|
||||
|
||||
const SCHEMA: [&str; 5] = [
|
||||
"CREATE TABLE IF NOT EXISTS settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
@@ -27,6 +36,13 @@ const SCHEMA: [&str; 4] = [
|
||||
content BLOB,
|
||||
PRIMARY KEY (project_path, file_path)
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS document_links (
|
||||
path TEXT PRIMARY KEY,
|
||||
document_id TEXT NOT NULL,
|
||||
base_hash TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
base_content TEXT
|
||||
)",
|
||||
"CREATE TABLE IF NOT EXISTS thumbnails (
|
||||
path TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
@@ -257,6 +273,56 @@ impl Store {
|
||||
})
|
||||
}
|
||||
|
||||
pub fn save_document_link(
|
||||
&self,
|
||||
path: &str,
|
||||
document_id: &str,
|
||||
base_hash: &str,
|
||||
role: &str,
|
||||
base_content: &str,
|
||||
) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute(
|
||||
"INSERT INTO document_links (path, document_id, base_hash, role, base_content)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5)
|
||||
ON CONFLICT(path) DO UPDATE SET
|
||||
document_id = excluded.document_id,
|
||||
base_hash = excluded.base_hash,
|
||||
role = excluded.role,
|
||||
base_content = excluded.base_content",
|
||||
params![path, document_id, base_hash, role, base_content],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn document_link(&self, path: &str) -> Result<Option<DocumentLink>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
.query_row(
|
||||
"SELECT document_id, base_hash, role, base_content
|
||||
FROM document_links WHERE path = ?1",
|
||||
params![path],
|
||||
|row| {
|
||||
Ok(DocumentLink {
|
||||
document_id: row.get(0)?,
|
||||
base_hash: row.get(1)?,
|
||||
role: row.get(2)?,
|
||||
base_content: row.get::<_, Option<String>>(3)?.unwrap_or_default(),
|
||||
})
|
||||
},
|
||||
)
|
||||
.optional()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn forget_document_link(&self, path: &str) -> Result<(), String> {
|
||||
self.with(|connection| {
|
||||
connection.execute("DELETE FROM document_links WHERE path = ?1", params![path])?;
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn thumbnail(&self, path: &str, modified: i64) -> Result<Option<(String, String)>, String> {
|
||||
self.with(|connection| {
|
||||
connection
|
||||
|
||||
+235
-1
@@ -57,6 +57,26 @@ fn load_project(
|
||||
Ok((dir, store.meta(project)?))
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct AppInfo {
|
||||
pub version: String,
|
||||
pub typst_version: String,
|
||||
pub authors: String,
|
||||
pub license: String,
|
||||
pub tauri_version: String,
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn app_info() -> AppInfo {
|
||||
AppInfo {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
typst_version: "0.14.2".to_string(),
|
||||
authors: "SirBlobby".to_string(),
|
||||
license: "Apache-2.0".to_string(),
|
||||
tauri_version: tauri::VERSION.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn get_settings(app: AppHandle, store: State<'_, Store>) -> Result<Settings, String> {
|
||||
load_settings(&app, &store)
|
||||
@@ -68,6 +88,8 @@ fn update_settings(
|
||||
store: State<'_, Store>,
|
||||
workspace_root: Option<String>,
|
||||
server_url: Option<String>,
|
||||
autosave_seconds: Option<u32>,
|
||||
sync_minutes: Option<u32>,
|
||||
) -> Result<Settings, String> {
|
||||
let mut settings = load_settings(&app, &store)?;
|
||||
if let Some(root) = workspace_root {
|
||||
@@ -79,6 +101,12 @@ fn update_settings(
|
||||
if let Some(url) = server_url {
|
||||
settings.server_url = url.trim_end_matches('/').to_string();
|
||||
}
|
||||
if let Some(seconds) = autosave_seconds {
|
||||
settings.autosave_seconds = seconds;
|
||||
}
|
||||
if let Some(minutes) = sync_minutes {
|
||||
settings.sync_minutes = minutes;
|
||||
}
|
||||
save_settings(&store, &settings)?;
|
||||
Ok(settings)
|
||||
}
|
||||
@@ -331,6 +359,7 @@ fn compile_target(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
entrypoint: Option<String>,
|
||||
overrides: Option<std::collections::HashMap<String, String>>,
|
||||
) -> Result<CompileResult, CompileFailure> {
|
||||
let target = resolve_target(&app, &store, &path).map_err(failure)?;
|
||||
@@ -340,7 +369,12 @@ fn compile_target(
|
||||
files.insert(file, content.into_bytes());
|
||||
}
|
||||
|
||||
compiler::compile_to_svg(target.entrypoint, files)
|
||||
let entrypoint = match entrypoint {
|
||||
Some(file) if files.contains_key(&file) => file,
|
||||
_ => target.entrypoint,
|
||||
};
|
||||
|
||||
compiler::compile_to_svg(entrypoint, files)
|
||||
.map_err(|diagnostics| CompileFailure { diagnostics })
|
||||
}
|
||||
|
||||
@@ -397,6 +431,87 @@ fn list_assets(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Asset>, St
|
||||
assets::list_assets(&app, &store)
|
||||
}
|
||||
|
||||
#[derive(Serialize)]
|
||||
pub struct Resource {
|
||||
pub name: String,
|
||||
pub reference: String,
|
||||
pub path: String,
|
||||
pub scope: String,
|
||||
pub kind: String,
|
||||
pub size: u64,
|
||||
pub font_families: Vec<String>,
|
||||
}
|
||||
|
||||
fn resource_kind(name: &str) -> String {
|
||||
if assets::is_font(name) {
|
||||
"font"
|
||||
} else if assets::is_image(name) {
|
||||
"image"
|
||||
} else {
|
||||
"file"
|
||||
}
|
||||
.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_resources(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<Vec<Resource>, String> {
|
||||
let mut resources = Vec::new();
|
||||
|
||||
for asset in assets::list_assets(&app, &store)? {
|
||||
resources.push(Resource {
|
||||
reference: asset.name.clone(),
|
||||
path: format!("{}/{}", assets::ASSETS_DIR, asset.name),
|
||||
scope: "shared".to_string(),
|
||||
kind: asset.kind,
|
||||
size: asset.size,
|
||||
font_families: asset.font_families,
|
||||
name: asset.name,
|
||||
});
|
||||
}
|
||||
|
||||
let target = resolve_target(&app, &store, &path)?;
|
||||
let prefix = if target.standalone {
|
||||
path.rsplit_once('/').map(|(dir, _)| dir).unwrap_or("")
|
||||
} else {
|
||||
path.as_str()
|
||||
};
|
||||
|
||||
for file in list_files(&target.root)? {
|
||||
if file.path.to_lowercase().ends_with(".typ") {
|
||||
continue;
|
||||
}
|
||||
|
||||
let full = target.root.join(&file.path);
|
||||
let font_families = if assets::is_font(&file.name) {
|
||||
std::fs::read(&full)
|
||||
.map(|data| assets::families_in(&data))
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
|
||||
resources.push(Resource {
|
||||
name: file.name,
|
||||
reference: file.path.clone(),
|
||||
path: if prefix.is_empty() {
|
||||
file.path.clone()
|
||||
} else {
|
||||
format!("{}/{}", prefix, file.path)
|
||||
},
|
||||
scope: "project".to_string(),
|
||||
kind: resource_kind(&file.path),
|
||||
size: file.size,
|
||||
font_families,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(resources)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn list_font_families(app: AppHandle, store: State<'_, Store>, path: Option<String>) -> Result<Vec<String>, String> {
|
||||
let files = match path {
|
||||
@@ -524,6 +639,115 @@ fn cloud_list_spaces(app: AppHandle, store: State<'_, Store>) -> Result<Vec<Spac
|
||||
sync::list_spaces(&server_url, &token)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_folders(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
) -> Result<Vec<sync::CloudFolder>, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_folders(&server_url, &token)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_documents(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
folder_id: Option<String>,
|
||||
) -> Result<Vec<sync::CloudDocument>, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_documents(&server_url, &token, folder_id.as_deref())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_list_shared(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
) -> Result<sync::SharedItems, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::list_shared(&server_url, &token)
|
||||
}
|
||||
|
||||
/// Downloads a cloud document into the workspace and remembers where it came
|
||||
/// from so it can be synced back.
|
||||
#[tauri::command]
|
||||
fn cloud_download_document(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
document_id: String,
|
||||
parent: String,
|
||||
) -> Result<String, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
let document = sync::pull_document(&server_url, &token, &document_id)?;
|
||||
|
||||
let mut name = document.title.replace('/', "-").trim().to_string();
|
||||
if name.is_empty() {
|
||||
name = "document".to_string();
|
||||
}
|
||||
if !name.to_lowercase().ends_with(".typ") {
|
||||
name.push_str(".typ");
|
||||
}
|
||||
|
||||
let path = join_path(&parent, &name);
|
||||
let full = workspace_path(&app, &store, &path)?;
|
||||
if let Some(dir) = full.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|e| e.to_string())?;
|
||||
}
|
||||
std::fs::write(&full, &document.content).map_err(|e| e.to_string())?;
|
||||
|
||||
store.save_document_link(
|
||||
&path,
|
||||
&document_id,
|
||||
&document.hash,
|
||||
&document.role,
|
||||
&document.content,
|
||||
)?;
|
||||
|
||||
Ok(path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_sync_document(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<SyncReport, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::sync_document(&server_url, &token, &app, &store, &path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_resolve_document(
|
||||
app: AppHandle,
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
content: String,
|
||||
server_hash: String,
|
||||
) -> Result<(), String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
sync::resolve_document_conflict(
|
||||
&server_url,
|
||||
&token,
|
||||
&app,
|
||||
&store,
|
||||
&path,
|
||||
&content,
|
||||
&server_hash,
|
||||
)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_unlink_document(store: State<'_, Store>, path: String) -> Result<(), String> {
|
||||
store.forget_document_link(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_document_link(
|
||||
store: State<'_, Store>,
|
||||
path: String,
|
||||
) -> Result<Option<db::DocumentLink>, String> {
|
||||
store.document_link(&path)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
fn cloud_create_space(app: AppHandle, store: State<'_, Store>, name: String) -> Result<SpaceSummary, String> {
|
||||
let (server_url, token) = cloud_credentials(&app, &store)?;
|
||||
@@ -667,6 +891,7 @@ pub fn run() {
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
app_info,
|
||||
get_settings,
|
||||
update_settings,
|
||||
browse_workspace,
|
||||
@@ -686,6 +911,7 @@ pub fn run() {
|
||||
read_image,
|
||||
clear_thumbnails,
|
||||
list_assets,
|
||||
list_resources,
|
||||
list_font_families,
|
||||
import_assets,
|
||||
delete_asset,
|
||||
@@ -699,6 +925,14 @@ pub fn run() {
|
||||
cloud_logout,
|
||||
cloud_account,
|
||||
cloud_list_spaces,
|
||||
cloud_list_folders,
|
||||
cloud_list_documents,
|
||||
cloud_list_shared,
|
||||
cloud_download_document,
|
||||
cloud_sync_document,
|
||||
cloud_resolve_document,
|
||||
cloud_document_link,
|
||||
cloud_unlink_document,
|
||||
cloud_create_space,
|
||||
cloud_delete_space,
|
||||
cloud_clone_space,
|
||||
|
||||
@@ -566,3 +566,285 @@ pub fn resolve_conflict(
|
||||
.insert(path.to_string(), server_hash.to_string());
|
||||
store.save_meta(project, meta)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct CloudFolder {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub parent_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize, Clone)]
|
||||
pub struct CloudDocument {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub folder_id: Option<String>,
|
||||
pub role: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct SharedItems {
|
||||
pub documents: Vec<CloudDocument>,
|
||||
pub spaces: Vec<SpaceSummary>,
|
||||
}
|
||||
|
||||
pub fn list_folders(server_url: &str, token: &str) -> Result<Vec<CloudFolder>, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/folders"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Vec<CloudFolder>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn list_documents(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
folder_id: Option<&str>,
|
||||
) -> Result<Vec<CloudDocument>, String> {
|
||||
let mut request = agent()
|
||||
.get(&endpoint(server_url, "/documents"))
|
||||
.set("Authorization", &format!("Bearer {}", token));
|
||||
|
||||
if let Some(folder) = folder_id {
|
||||
request = request.query("folder_id", folder);
|
||||
}
|
||||
|
||||
request
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<Vec<CloudDocument>>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
pub fn list_shared(server_url: &str, token: &str) -> Result<SharedItems, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, "/shared"))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<SharedItems>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Serialize)]
|
||||
pub struct DocumentContent {
|
||||
pub id: String,
|
||||
pub title: String,
|
||||
pub role: String,
|
||||
pub hash: String,
|
||||
pub content: String,
|
||||
}
|
||||
|
||||
pub fn pull_document(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
document_id: &str,
|
||||
) -> Result<DocumentContent, String> {
|
||||
agent()
|
||||
.get(&endpoint(server_url, &format!("/documents/{}", document_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.call()
|
||||
.map_err(describe)?
|
||||
.into_json::<DocumentContent>()
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Syncs one downloaded cloud document. The merge base is the copy stored when
|
||||
/// the document was last exchanged with the server.
|
||||
pub fn sync_document(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
app: &tauri::AppHandle,
|
||||
store: &Store,
|
||||
path: &str,
|
||||
) -> Result<SyncReport, String> {
|
||||
let link = store
|
||||
.document_link(path)?
|
||||
.ok_or("This document is not linked to the cloud")?;
|
||||
|
||||
let full = crate::workspace::workspace_path(app, store, path)?;
|
||||
let local = std::fs::read_to_string(&full).map_err(|e| e.to_string())?;
|
||||
let remote = pull_document(server_url, token, &link.document_id)?;
|
||||
|
||||
let mut report = SyncReport::default();
|
||||
|
||||
let local_hash = content_hash(local.as_bytes());
|
||||
let editable = remote.role == "owner" || remote.role == "editor";
|
||||
|
||||
if local_hash == remote.hash {
|
||||
store.save_document_link(path, &link.document_id, &remote.hash, &remote.role, &local)?;
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if local_hash == link.base_hash {
|
||||
std::fs::write(&full, &remote.content).map_err(|e| e.to_string())?;
|
||||
store.save_document_link(
|
||||
path,
|
||||
&link.document_id,
|
||||
&remote.hash,
|
||||
&remote.role,
|
||||
&remote.content,
|
||||
)?;
|
||||
report.pulled.push(path.to_string());
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
if remote.hash == link.base_hash {
|
||||
if !editable {
|
||||
return Err("You only have view access to this document".to_string());
|
||||
}
|
||||
match push_document(
|
||||
server_url,
|
||||
token,
|
||||
&link.document_id,
|
||||
&local,
|
||||
Some(&link.base_hash),
|
||||
)? {
|
||||
PushResult::Applied => {
|
||||
store.save_document_link(
|
||||
path,
|
||||
&link.document_id,
|
||||
&local_hash,
|
||||
&remote.role,
|
||||
&local,
|
||||
)?;
|
||||
report.pushed.push(path.to_string());
|
||||
}
|
||||
PushResult::Conflict {
|
||||
server_hash,
|
||||
server_text,
|
||||
} => {
|
||||
report.conflicts.push(Conflict {
|
||||
path: path.to_string(),
|
||||
local_text: local,
|
||||
remote_text: server_text.clone(),
|
||||
merged_text: server_text,
|
||||
server_hash,
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
return Ok(report);
|
||||
}
|
||||
|
||||
match diffy::merge(&link.base_content, &local, &remote.content) {
|
||||
Ok(merged) => {
|
||||
std::fs::write(&full, &merged).map_err(|e| e.to_string())?;
|
||||
|
||||
if editable {
|
||||
match push_document(
|
||||
server_url,
|
||||
token,
|
||||
&link.document_id,
|
||||
&merged,
|
||||
Some(&remote.hash),
|
||||
)? {
|
||||
PushResult::Applied => {
|
||||
store.save_document_link(
|
||||
path,
|
||||
&link.document_id,
|
||||
&content_hash(merged.as_bytes()),
|
||||
&remote.role,
|
||||
&merged,
|
||||
)?;
|
||||
}
|
||||
PushResult::Conflict {
|
||||
server_hash,
|
||||
server_text,
|
||||
} => {
|
||||
report.conflicts.push(Conflict {
|
||||
path: path.to_string(),
|
||||
local_text: merged.clone(),
|
||||
remote_text: server_text.clone(),
|
||||
merged_text: server_text,
|
||||
server_hash,
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
});
|
||||
return Ok(report);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
report.merged.push(path.to_string());
|
||||
}
|
||||
Err(conflicted) => {
|
||||
report.conflicts.push(Conflict {
|
||||
path: path.to_string(),
|
||||
local_text: local,
|
||||
remote_text: remote.content,
|
||||
merged_text: conflicted,
|
||||
server_hash: remote.hash,
|
||||
auto_merged: false,
|
||||
binary: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Applies a resolved cloud document and uploads it.
|
||||
pub fn resolve_document_conflict(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
app: &tauri::AppHandle,
|
||||
store: &Store,
|
||||
path: &str,
|
||||
content: &str,
|
||||
server_hash: &str,
|
||||
) -> Result<(), String> {
|
||||
let link = store
|
||||
.document_link(path)?
|
||||
.ok_or("This document is not linked to the cloud")?;
|
||||
|
||||
let full = crate::workspace::workspace_path(app, store, path)?;
|
||||
std::fs::write(&full, content).map_err(|e| e.to_string())?;
|
||||
|
||||
match push_document(server_url, token, &link.document_id, content, Some(server_hash))? {
|
||||
PushResult::Applied => store.save_document_link(
|
||||
path,
|
||||
&link.document_id,
|
||||
&content_hash(content.as_bytes()),
|
||||
&link.role,
|
||||
content,
|
||||
),
|
||||
PushResult::Conflict { .. } => {
|
||||
Err("The document changed again in the cloud. Sync and merge once more.".to_string())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn push_document(
|
||||
server_url: &str,
|
||||
token: &str,
|
||||
document_id: &str,
|
||||
content: &str,
|
||||
base_hash: Option<&str>,
|
||||
) -> Result<PushResult, String> {
|
||||
let response = agent()
|
||||
.put(&endpoint(server_url, &format!("/documents/{}", document_id)))
|
||||
.set("Authorization", &format!("Bearer {}", token))
|
||||
.send_json(ureq::json!({
|
||||
"content": content,
|
||||
"base_hash": base_hash,
|
||||
}));
|
||||
|
||||
match response {
|
||||
Ok(_) => Ok(PushResult::Applied),
|
||||
Err(ureq::Error::Status(409, body)) => {
|
||||
let conflict = body
|
||||
.into_json::<ConflictBody>()
|
||||
.map_err(|e| format!("Malformed conflict response: {}", e))?;
|
||||
Ok(PushResult::Conflict {
|
||||
server_hash: conflict.server_hash,
|
||||
server_text: conflict.server_content,
|
||||
})
|
||||
}
|
||||
Err(other) => Err(describe(other)),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -35,6 +35,10 @@ pub struct Settings {
|
||||
pub account_email: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_username: Option<String>,
|
||||
#[serde(default)]
|
||||
pub autosave_seconds: u32,
|
||||
#[serde(default)]
|
||||
pub sync_minutes: u32,
|
||||
}
|
||||
|
||||
impl Settings {
|
||||
@@ -49,6 +53,8 @@ impl Settings {
|
||||
device_token: None,
|
||||
account_email: None,
|
||||
account_username: None,
|
||||
autosave_seconds: 5,
|
||||
sync_minutes: 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user