Refactor IndexedDB document persistence to reuse data structures of desktop persistence (#4020)
* Switch indexedDb document serialized struct from camelCase to snake_case * Refactor and migrate old indexedDb format to the same shape as desktop persistence * Avoid duplicate struct definitions in the desktop crate * Refactor frontend message handling to consolidate auto-save document loading * Code review * Review --------- Co-authored-by: Timon <me@timon.zip>
This commit is contained in:
parent
79d778a535
commit
60f16d72a5
|
|
@ -314,39 +314,19 @@ impl App {
|
||||||
responses.push(message);
|
responses.push(message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DesktopFrontendMessage::PersistenceLoadCurrentDocument => {
|
DesktopFrontendMessage::PersistenceLoadDocuments => {
|
||||||
if let Some((id, document)) = self.persistent_data.current_document() {
|
// Open all documents in persisted tab order, then select the current one
|
||||||
let message = DesktopWrapperMessage::LoadDocument {
|
for (id, document) in self.persistent_data.documents() {
|
||||||
id,
|
responses.push(DesktopWrapperMessage::LoadDocument {
|
||||||
document,
|
|
||||||
to_front: false,
|
|
||||||
select_after_open: true,
|
|
||||||
};
|
|
||||||
responses.push(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
DesktopFrontendMessage::PersistenceLoadRemainingDocuments => {
|
|
||||||
for (id, document) in self.persistent_data.documents_before_current().into_iter().rev() {
|
|
||||||
let message = DesktopWrapperMessage::LoadDocument {
|
|
||||||
id,
|
|
||||||
document,
|
|
||||||
to_front: true,
|
|
||||||
select_after_open: false,
|
|
||||||
};
|
|
||||||
responses.push(message);
|
|
||||||
}
|
|
||||||
for (id, document) in self.persistent_data.documents_after_current() {
|
|
||||||
let message = DesktopWrapperMessage::LoadDocument {
|
|
||||||
id,
|
id,
|
||||||
document,
|
document,
|
||||||
to_front: false,
|
to_front: false,
|
||||||
select_after_open: false,
|
select_after_open: false,
|
||||||
};
|
});
|
||||||
responses.push(message);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(id) = self.persistent_data.current_document_id() {
|
if let Some(id) = self.persistent_data.current_document_id() {
|
||||||
let message = DesktopWrapperMessage::SelectDocument { id };
|
responses.push(DesktopWrapperMessage::SelectDocument { id });
|
||||||
responses.push(message);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
DesktopFrontendMessage::OpenLaunchDocuments => {
|
DesktopFrontendMessage::OpenLaunchDocuments => {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
use crate::wrapper::messages::{Document, DocumentId};
|
use crate::wrapper::messages::{Document, DocumentId, PersistedDocumentInfo};
|
||||||
|
|
||||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
||||||
pub(crate) struct PersistentData {
|
pub(crate) struct PersistentData {
|
||||||
documents: DocumentStore,
|
documents: Vec<PersistedDocumentInfo>,
|
||||||
current_document: Option<DocumentId>,
|
current_document: Option<DocumentId>,
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
document_order: Option<Vec<DocumentId>>,
|
document_order: Option<Vec<DocumentId>>,
|
||||||
|
|
@ -10,10 +10,22 @@ pub(crate) struct PersistentData {
|
||||||
|
|
||||||
impl PersistentData {
|
impl PersistentData {
|
||||||
pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) {
|
pub(crate) fn write_document(&mut self, id: DocumentId, document: Document) {
|
||||||
self.documents.write(id, document);
|
let info = PersistedDocumentInfo {
|
||||||
if let Some(order) = &self.document_order {
|
id,
|
||||||
self.documents.force_order(order);
|
name: document.name.clone(),
|
||||||
|
path: document.path.clone(),
|
||||||
|
is_saved: document.is_saved,
|
||||||
|
};
|
||||||
|
if let Some(existing) = self.documents.iter_mut().find(|doc| doc.id == id) {
|
||||||
|
*existing = info;
|
||||||
|
} else {
|
||||||
|
self.documents.push(info);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let Err(e) = std::fs::write(Self::document_content_path(&id), document.content) {
|
||||||
|
tracing::error!("Failed to write document {id:?} to disk: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
self.flush();
|
self.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -21,45 +33,24 @@ impl PersistentData {
|
||||||
if Some(*id) == self.current_document {
|
if Some(*id) == self.current_document {
|
||||||
self.current_document = None;
|
self.current_document = None;
|
||||||
}
|
}
|
||||||
self.documents.delete(id);
|
|
||||||
|
self.documents.retain(|doc| doc.id != *id);
|
||||||
|
if let Err(e) = std::fs::remove_file(Self::document_content_path(id)) {
|
||||||
|
tracing::error!("Failed to delete document {id:?} from disk: {e}");
|
||||||
|
}
|
||||||
|
|
||||||
self.flush();
|
self.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn current_document_id(&self) -> Option<DocumentId> {
|
pub(crate) fn current_document_id(&self) -> Option<DocumentId> {
|
||||||
match self.current_document {
|
match self.current_document {
|
||||||
Some(id) => Some(id),
|
Some(id) => Some(id),
|
||||||
None => Some(*self.documents.document_ids().first()?),
|
None => Some(self.documents.first()?.id),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn current_document(&self) -> Option<(DocumentId, Document)> {
|
pub(crate) fn documents(&self) -> Vec<(DocumentId, Document)> {
|
||||||
let current_id = self.current_document_id()?;
|
self.documents.iter().filter_map(|doc| Some((doc.id, self.read_document(&doc.id)?))).collect()
|
||||||
Some((current_id, self.documents.read(¤t_id)?))
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn documents_before_current(&self) -> Vec<(DocumentId, Document)> {
|
|
||||||
let Some(current_id) = self.current_document_id() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
self.documents
|
|
||||||
.document_ids()
|
|
||||||
.into_iter()
|
|
||||||
.take_while(|id| *id != current_id)
|
|
||||||
.filter_map(|id| Some((id, self.documents.read(&id)?)))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
pub(crate) fn documents_after_current(&self) -> Vec<(DocumentId, Document)> {
|
|
||||||
let Some(current_id) = self.current_document_id() else {
|
|
||||||
return Vec::new();
|
|
||||||
};
|
|
||||||
self.documents
|
|
||||||
.document_ids()
|
|
||||||
.into_iter()
|
|
||||||
.skip_while(|id| *id != current_id)
|
|
||||||
.skip(1)
|
|
||||||
.filter_map(|id| Some((id, self.documents.read(&id)?)))
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn set_current_document(&mut self, id: DocumentId) {
|
pub(crate) fn set_current_document(&mut self, id: DocumentId) {
|
||||||
|
|
@ -68,11 +59,31 @@ impl PersistentData {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub(crate) fn force_document_order(&mut self, order: Vec<DocumentId>) {
|
pub(crate) fn force_document_order(&mut self, order: Vec<DocumentId>) {
|
||||||
|
let mut ordered_prefix_length = 0;
|
||||||
|
for id in &order {
|
||||||
|
if let Some(offset) = self.documents[ordered_prefix_length..].iter().position(|doc| doc.id == *id) {
|
||||||
|
let found_index = ordered_prefix_length + offset;
|
||||||
|
if found_index != ordered_prefix_length {
|
||||||
|
self.documents[ordered_prefix_length..=found_index].rotate_right(1);
|
||||||
|
}
|
||||||
|
ordered_prefix_length += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
self.document_order = Some(order);
|
self.document_order = Some(order);
|
||||||
self.documents.force_order(self.document_order.as_ref().unwrap());
|
|
||||||
self.flush();
|
self.flush();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn read_document(&self, id: &DocumentId) -> Option<Document> {
|
||||||
|
let info = self.documents.iter().find(|doc| doc.id == *id)?;
|
||||||
|
let content = std::fs::read_to_string(Self::document_content_path(id)).ok()?;
|
||||||
|
Some(Document {
|
||||||
|
content,
|
||||||
|
name: info.name.clone(),
|
||||||
|
path: info.path.clone(),
|
||||||
|
is_saved: info.is_saved,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn flush(&self) {
|
fn flush(&self) {
|
||||||
let data = match ron::ser::to_string_pretty(self, Default::default()) {
|
let data = match ron::ser::to_string_pretty(self, Default::default()) {
|
||||||
Ok(d) => d,
|
Ok(d) => d,
|
||||||
|
|
@ -107,6 +118,31 @@ impl PersistentData {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
*self = loaded;
|
*self = loaded;
|
||||||
|
|
||||||
|
self.garbage_collect_document_files();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn garbage_collect_document_files(&self) {
|
||||||
|
let valid_paths: std::collections::HashSet<_> = self.documents.iter().map(|doc| Self::document_content_path(&doc.id)).collect();
|
||||||
|
|
||||||
|
let directory = crate::dirs::app_autosave_documents_dir();
|
||||||
|
let entries = match std::fs::read_dir(&directory) {
|
||||||
|
Ok(entries) => entries,
|
||||||
|
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to read autosave documents directory: {e}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
for entry in entries.flatten() {
|
||||||
|
let path = entry.path();
|
||||||
|
if path.is_file() && !valid_paths.contains(&path) {
|
||||||
|
if let Err(e) = std::fs::remove_file(&path) {
|
||||||
|
tracing::error!("Failed to remove orphaned document file {path:?}: {e}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn state_file_path() -> std::path::PathBuf {
|
fn state_file_path() -> std::path::PathBuf {
|
||||||
|
|
@ -114,79 +150,10 @@ impl PersistentData {
|
||||||
path.push(crate::consts::APP_STATE_FILE_NAME);
|
path.push(crate::consts::APP_STATE_FILE_NAME);
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Default, serde::Serialize, serde::Deserialize)]
|
fn document_content_path(id: &DocumentId) -> std::path::PathBuf {
|
||||||
struct DocumentStore(Vec<DocumentInfo>);
|
|
||||||
impl DocumentStore {
|
|
||||||
fn write(&mut self, id: DocumentId, document: Document) {
|
|
||||||
let meta = DocumentInfo::new(id, &document);
|
|
||||||
if let Some(existing) = self.0.iter_mut().find(|meta| meta.id == id) {
|
|
||||||
*existing = meta;
|
|
||||||
} else {
|
|
||||||
self.0.push(meta);
|
|
||||||
}
|
|
||||||
if let Err(e) = std::fs::write(Self::document_path(&id), document.content) {
|
|
||||||
tracing::error!("Failed to write document {id:?} to disk: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn delete(&mut self, id: &DocumentId) {
|
|
||||||
self.0.retain(|meta| meta.id != *id);
|
|
||||||
if let Err(e) = std::fs::remove_file(Self::document_path(id)) {
|
|
||||||
tracing::error!("Failed to delete document {id:?} from disk: {e}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read(&self, id: &DocumentId) -> Option<Document> {
|
|
||||||
let meta = self.0.iter().find(|meta| meta.id == *id)?;
|
|
||||||
let content = std::fs::read_to_string(Self::document_path(id)).ok()?;
|
|
||||||
Some(Document {
|
|
||||||
content,
|
|
||||||
name: meta.name.clone(),
|
|
||||||
path: meta.path.clone(),
|
|
||||||
is_saved: meta.is_saved,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn force_order(&mut self, desired_order: &[DocumentId]) {
|
|
||||||
let mut ordered_prefix_len = 0;
|
|
||||||
for id in desired_order {
|
|
||||||
if let Some(offset) = self.0[ordered_prefix_len..].iter().position(|meta| meta.id == *id) {
|
|
||||||
let found_index = ordered_prefix_len + offset;
|
|
||||||
if found_index != ordered_prefix_len {
|
|
||||||
self.0[ordered_prefix_len..=found_index].rotate_right(1);
|
|
||||||
}
|
|
||||||
ordered_prefix_len += 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn document_ids(&self) -> Vec<DocumentId> {
|
|
||||||
self.0.iter().map(|meta| meta.id).collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
fn document_path(id: &DocumentId) -> std::path::PathBuf {
|
|
||||||
let mut path = crate::dirs::app_autosave_documents_dir();
|
let mut path = crate::dirs::app_autosave_documents_dir();
|
||||||
path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION));
|
path.push(format!("{:x}.{}", id.0, graphite_desktop_wrapper::FILE_EXTENSION));
|
||||||
path
|
path
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(serde::Serialize, serde::Deserialize)]
|
|
||||||
struct DocumentInfo {
|
|
||||||
id: DocumentId,
|
|
||||||
name: String,
|
|
||||||
path: Option<std::path::PathBuf>,
|
|
||||||
is_saved: bool,
|
|
||||||
}
|
|
||||||
impl DocumentInfo {
|
|
||||||
fn new(id: DocumentId, Document { name, path, is_saved, .. }: &Document) -> Self {
|
|
||||||
Self {
|
|
||||||
id,
|
|
||||||
name: name.clone(),
|
|
||||||
path: path.clone(),
|
|
||||||
is_saved: *is_saved,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -71,9 +71,9 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
|
||||||
dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument {
|
dispatcher.respond(DesktopFrontendMessage::PersistenceWriteDocument {
|
||||||
id: document_id,
|
id: document_id,
|
||||||
document: Document {
|
document: Document {
|
||||||
|
content: document,
|
||||||
name: details.name,
|
name: details.name,
|
||||||
path: details.path,
|
path: details.path,
|
||||||
content: document,
|
|
||||||
is_saved: details.is_saved,
|
is_saved: details.is_saved,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -95,11 +95,8 @@ pub(super) fn intercept_frontend_message(dispatcher: &mut DesktopWrapperMessageD
|
||||||
// Forward this to update the UI
|
// Forward this to update the UI
|
||||||
return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents });
|
return Some(FrontendMessage::UpdateOpenDocumentsList { open_documents });
|
||||||
}
|
}
|
||||||
FrontendMessage::TriggerLoadFirstAutoSaveDocument => {
|
FrontendMessage::TriggerLoadAutoSaveDocuments => {
|
||||||
dispatcher.respond(DesktopFrontendMessage::PersistenceLoadCurrentDocument);
|
dispatcher.respond(DesktopFrontendMessage::PersistenceLoadDocuments);
|
||||||
}
|
|
||||||
FrontendMessage::TriggerLoadRestAutoSaveDocuments => {
|
|
||||||
dispatcher.respond(DesktopFrontendMessage::PersistenceLoadRemainingDocuments);
|
|
||||||
}
|
}
|
||||||
FrontendMessage::TriggerOpenLaunchDocuments => {
|
FrontendMessage::TriggerOpenLaunchDocuments => {
|
||||||
dispatcher.respond(DesktopFrontendMessage::OpenLaunchDocuments);
|
dispatcher.respond(DesktopFrontendMessage::OpenLaunchDocuments);
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ use std::path::PathBuf;
|
||||||
|
|
||||||
pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage;
|
pub(crate) use graphite_editor::messages::prelude::Message as EditorMessage;
|
||||||
|
|
||||||
|
pub use graphite_editor::messages::frontend::utility_types::{PersistedDocumentInfo, PersistedState};
|
||||||
pub use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
|
pub use graphite_editor::messages::input_mapper::utility_types::input_keyboard::{Key, ModifierKeys};
|
||||||
pub use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys};
|
pub use graphite_editor::messages::input_mapper::utility_types::input_mouse::{EditorMouseState as MouseState, EditorPosition as Position, MouseKeys};
|
||||||
pub use graphite_editor::messages::prelude::DocumentId;
|
pub use graphite_editor::messages::prelude::DocumentId;
|
||||||
|
|
@ -49,8 +50,7 @@ pub enum DesktopFrontendMessage {
|
||||||
PersistenceUpdateCurrentDocument {
|
PersistenceUpdateCurrentDocument {
|
||||||
id: DocumentId,
|
id: DocumentId,
|
||||||
},
|
},
|
||||||
PersistenceLoadCurrentDocument,
|
PersistenceLoadDocuments,
|
||||||
PersistenceLoadRemainingDocuments,
|
|
||||||
PersistenceUpdateDocumentsList {
|
PersistenceUpdateDocumentsList {
|
||||||
ids: Vec<DocumentId>,
|
ids: Vec<DocumentId>,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -123,8 +123,7 @@ pub enum FrontendMessage {
|
||||||
document: String,
|
document: String,
|
||||||
details: DocumentDetails,
|
details: DocumentDetails,
|
||||||
},
|
},
|
||||||
TriggerLoadFirstAutoSaveDocument,
|
TriggerLoadAutoSaveDocuments,
|
||||||
TriggerLoadRestAutoSaveDocuments,
|
|
||||||
TriggerOpenLaunchDocuments,
|
TriggerOpenLaunchDocuments,
|
||||||
TriggerLoadPreferences,
|
TriggerLoadPreferences,
|
||||||
TriggerLoadWorkspaceLayout,
|
TriggerLoadWorkspaceLayout,
|
||||||
|
|
|
||||||
|
|
@ -15,12 +15,29 @@ pub struct OpenDocument {
|
||||||
pub struct DocumentDetails {
|
pub struct DocumentDetails {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub path: Option<PathBuf>,
|
pub path: Option<PathBuf>,
|
||||||
#[serde(rename = "isSaved")]
|
#[serde(alias = "isSaved")]
|
||||||
pub is_saved: bool,
|
pub is_saved: bool,
|
||||||
#[serde(rename = "isAutoSaved")]
|
#[serde(alias = "isAutoSaved")]
|
||||||
pub is_auto_saved: bool,
|
pub is_auto_saved: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
||||||
|
#[derive(Clone, Debug, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PersistedDocumentInfo {
|
||||||
|
pub id: DocumentId,
|
||||||
|
pub name: String,
|
||||||
|
#[serde(default)]
|
||||||
|
pub path: Option<PathBuf>,
|
||||||
|
pub is_saved: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify), tsify(large_number_types_as_bigints))]
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct PersistedState {
|
||||||
|
pub documents: Vec<PersistedDocumentInfo>,
|
||||||
|
pub current_document: Option<DocumentId>,
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
#[cfg_attr(feature = "wasm", derive(tsify::Tsify))]
|
||||||
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, serde::Serialize, serde::Deserialize)]
|
||||||
pub enum MouseCursorIcon {
|
pub enum MouseCursorIcon {
|
||||||
|
|
|
||||||
|
|
@ -109,8 +109,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
// Before loading any documents, initially prepare the welcome screen buttons layout
|
// Before loading any documents, initially prepare the welcome screen buttons layout
|
||||||
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
|
responses.add(PortfolioMessage::RequestWelcomeScreenButtonsLayout);
|
||||||
|
|
||||||
// Tell frontend to load the current document
|
// Tell frontend to load persistent auto-saved documents (placed early so IndexedDB reads overlap with subsequent UI setup)
|
||||||
responses.add(FrontendMessage::TriggerLoadFirstAutoSaveDocument);
|
responses.add(FrontendMessage::TriggerLoadAutoSaveDocuments);
|
||||||
|
|
||||||
|
// Tell frontend to load documents passed in as launch arguments
|
||||||
|
responses.add(FrontendMessage::TriggerOpenLaunchDocuments);
|
||||||
|
|
||||||
// Display the menu bar at the top of the window
|
// Display the menu bar at the top of the window
|
||||||
responses.add(MenuBarMessage::SendLayout);
|
responses.add(MenuBarMessage::SendLayout);
|
||||||
|
|
@ -118,11 +121,8 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
// Send the initial workspace panel layout to the frontend
|
// Send the initial workspace panel layout to the frontend
|
||||||
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
responses.add(PortfolioMessage::UpdateWorkspacePanelLayout);
|
||||||
|
|
||||||
// Send the information for tooltips and categories for each node/input.
|
// Request status bar info layout
|
||||||
responses.add(FrontendMessage::SendUIMetadata {
|
responses.add(PortfolioMessage::RequestStatusBarInfoLayout);
|
||||||
node_descriptions: document_node_definitions::collect_node_descriptions(),
|
|
||||||
node_types: document_node_definitions::collect_node_types(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Send shortcuts for widgets created in the frontend which need shortcut tooltips
|
// Send shortcuts for widgets created in the frontend which need shortcut tooltips
|
||||||
responses.add(FrontendMessage::SendShortcutFullscreen {
|
responses.add(FrontendMessage::SendShortcutFullscreen {
|
||||||
|
|
@ -136,14 +136,11 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
|
||||||
shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft),
|
shortcut: action_shortcut_manual!(Key::Shift, Key::MouseLeft),
|
||||||
});
|
});
|
||||||
|
|
||||||
// Request status bar info layout
|
// Send the information for tooltips and categories for each node/input.
|
||||||
responses.add(PortfolioMessage::RequestStatusBarInfoLayout);
|
responses.add(FrontendMessage::SendUIMetadata {
|
||||||
|
node_descriptions: document_node_definitions::collect_node_descriptions(),
|
||||||
// Tell frontend to finish loading persistent documents
|
node_types: document_node_definitions::collect_node_types(),
|
||||||
responses.add(FrontendMessage::TriggerLoadRestAutoSaveDocuments);
|
});
|
||||||
|
|
||||||
// Tell frontend to load documented passed in as launch arguments
|
|
||||||
responses.add(FrontendMessage::TriggerOpenLaunchDocuments);
|
|
||||||
}
|
}
|
||||||
PortfolioMessage::DocumentPassMessage { document_id, message } => {
|
PortfolioMessage::DocumentPassMessage { document_id, message } => {
|
||||||
if let Some(document) = self.documents.get_mut(&document_id) {
|
if let Some(document) = self.documents.get_mut(&document_id) {
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@
|
||||||
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
$: resolvedSizes = subdivision && "Split" in subdivision ? subdivision.Split.children.map((child, index) => sizeOverrides[index] ?? child.size) : [];
|
||||||
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
|
$: documentTabLabels = $portfolio.documents.map((doc: OpenDocument) => {
|
||||||
const name = doc.details.name;
|
const name = doc.details.name;
|
||||||
const unsaved = !doc.details.isSaved;
|
const unsaved = !doc.details.is_saved;
|
||||||
if (!editor.inDevelopmentMode()) return { name, unsaved };
|
if (!editor.inDevelopmentMode()) return { name, unsaved };
|
||||||
|
|
||||||
const tooltipDescription = `Document ID: ${doc.id}`;
|
const tooltipDescription = `Document ID: ${doc.id}`;
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,7 @@ import {
|
||||||
loadWorkspaceLayout,
|
loadWorkspaceLayout,
|
||||||
storeDocument,
|
storeDocument,
|
||||||
removeDocument,
|
removeDocument,
|
||||||
loadFirstDocument,
|
loadDocuments,
|
||||||
loadRestDocuments,
|
|
||||||
saveActiveDocument,
|
saveActiveDocument,
|
||||||
} from "/src/utility-functions/persistence";
|
} from "/src/utility-functions/persistence";
|
||||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
@ -48,12 +47,8 @@ export function createPersistenceManager(subscriptions: SubscriptionsRouter, edi
|
||||||
await removeDocument(String(data.documentId), portfolio);
|
await removeDocument(String(data.documentId), portfolio);
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument", async () => {
|
subscriptions.subscribeFrontendMessage("TriggerLoadAutoSaveDocuments", async () => {
|
||||||
await loadFirstDocument(editor);
|
await loadDocuments(editor);
|
||||||
});
|
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments", async () => {
|
|
||||||
await loadRestDocuments(editor);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
|
subscriptions.subscribeFrontendMessage("TriggerOpenLaunchDocuments", async () => {
|
||||||
|
|
@ -75,8 +70,7 @@ export function destroyPersistenceManager() {
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
|
subscriptions.unsubscribeFrontendMessage("TriggerLoadWorkspaceLayout");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
|
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceWriteDocument");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
|
subscriptions.unsubscribeFrontendMessage("TriggerPersistenceRemoveDocument");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadFirstAutoSaveDocument");
|
subscriptions.unsubscribeFrontendMessage("TriggerLoadAutoSaveDocuments");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerLoadRestAutoSaveDocuments");
|
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
|
subscriptions.unsubscribeFrontendMessage("TriggerOpenLaunchDocuments");
|
||||||
subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
|
subscriptions.unsubscribeFrontendMessage("TriggerSaveActiveDocument");
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -247,7 +247,7 @@ export function onModifyInputField(e: CustomEvent) {
|
||||||
|
|
||||||
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) {
|
export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper, portfolioStore: PortfolioStore) {
|
||||||
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
|
const activeDocument = get(portfolioStore).documents[get(portfolioStore).activeDocumentIndex];
|
||||||
if (activeDocument && !activeDocument.details.isAutoSaved) editor.triggerAutoSave(activeDocument.id);
|
if (activeDocument && !activeDocument.details.is_auto_saved) editor.triggerAutoSave(activeDocument.id);
|
||||||
|
|
||||||
// Skip the message if the editor crashed, since work is already lost
|
// Skip the message if the editor crashed, since work is already lost
|
||||||
if (await editor.hasCrashed()) return;
|
if (await editor.hasCrashed()) return;
|
||||||
|
|
@ -255,7 +255,7 @@ export async function onBeforeUnload(e: BeforeUnloadEvent, editor: EditorWrapper
|
||||||
// Skip the message during development, since it's annoying when testing
|
// Skip the message during development, since it's annoying when testing
|
||||||
if (await editor.inDevelopmentMode()) return;
|
if (await editor.inDevelopmentMode()) return;
|
||||||
|
|
||||||
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.isSaved, true);
|
const allDocumentsSaved = get(portfolioStore).documents.reduce((acc, doc) => acc && doc.details.is_saved, true);
|
||||||
if (!allDocumentsSaved) {
|
if (!allDocumentsSaved) {
|
||||||
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
e.returnValue = "Unsaved work will be lost if the web browser tab is closed. Close anyway?";
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
|
||||||
|
|
@ -1,157 +1,150 @@
|
||||||
import { get } from "svelte/store";
|
import { get } from "svelte/store";
|
||||||
import type { PortfolioStore } from "/src/stores/portfolio";
|
import type { PortfolioStore } from "/src/stores/portfolio";
|
||||||
import type { MessageBody } from "/src/subscriptions-router";
|
import type { MessageBody } from "/src/subscriptions-router";
|
||||||
import type { EditorWrapper } from "/wrapper/pkg/graphite_wasm_wrapper";
|
import type { EditorWrapper, PersistedDocumentInfo, PersistedState } from "/wrapper/pkg/graphite_wasm_wrapper";
|
||||||
|
|
||||||
const PERSISTENCE_DB = "graphite";
|
const PERSISTENCE_DB = "graphite";
|
||||||
const PERSISTENCE_STORE = "store";
|
const PERSISTENCE_STORE = "store";
|
||||||
|
|
||||||
export async function storeDocumentTabOrder(portfolio: PortfolioStore) {
|
function emptyPersistedState(): PersistedState {
|
||||||
const documentOrder = get(portfolio).documents.map((doc) => String(doc.id));
|
// eslint-disable-next-line camelcase
|
||||||
await databaseSet("documents_tab_order", documentOrder);
|
return { documents: [], current_document: undefined };
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeCurrentDocumentId(documentId: string) {
|
function createDocumentInfo(id: bigint, name: string, isSaved: boolean): PersistedDocumentInfo {
|
||||||
await databaseSet("current_document_id", String(documentId));
|
// eslint-disable-next-line camelcase
|
||||||
|
return { id, name, is_saved: isSaved };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reorder document entries to match the given ID ordering, appending any unmentioned entries at the end
|
||||||
|
function reorderDocuments(documents: PersistedDocumentInfo[], orderedIds: bigint[]): PersistedDocumentInfo[] {
|
||||||
|
const byId = new Map(documents.map((entry) => [entry.id, entry]));
|
||||||
|
const reordered: PersistedDocumentInfo[] = [];
|
||||||
|
|
||||||
|
orderedIds.forEach((id) => {
|
||||||
|
const existing = byId.get(id);
|
||||||
|
if (existing) {
|
||||||
|
reordered.push(existing);
|
||||||
|
byId.delete(id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Append any entries not yet present in the portfolio (e.g. documents still loading at startup)
|
||||||
|
byId.forEach((entry) => reordered.push(entry));
|
||||||
|
|
||||||
|
return reordered;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ====================================
|
||||||
|
// State-based persistence (new format)
|
||||||
|
// ====================================
|
||||||
|
|
||||||
|
export async function storeDocumentTabOrder(portfolio: PortfolioStore) {
|
||||||
|
const portfolioData = get(portfolio);
|
||||||
|
const orderedIds = portfolioData.documents.map((doc) => doc.id);
|
||||||
|
|
||||||
|
await databaseUpdate<PersistedState>("state", (old) => {
|
||||||
|
const state = old || emptyPersistedState();
|
||||||
|
return { ...state, documents: reorderDocuments(state.documents, orderedIds) };
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
|
export async function storeDocument(autoSaveDocument: MessageBody<"TriggerPersistenceWriteDocument">, portfolio: PortfolioStore) {
|
||||||
await databaseUpdate<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", (old) => {
|
const { documentId, document, details } = autoSaveDocument;
|
||||||
|
|
||||||
|
// Update content in the documents store
|
||||||
|
await databaseUpdate<Record<string, string>>("documents", (old) => {
|
||||||
const documents = old || {};
|
const documents = old || {};
|
||||||
documents[String(autoSaveDocument.documentId)] = autoSaveDocument;
|
documents[String(documentId)] = document;
|
||||||
return documents;
|
return documents;
|
||||||
});
|
});
|
||||||
|
|
||||||
await storeDocumentTabOrder(portfolio);
|
// Update metadata and ordering in the state store
|
||||||
await storeCurrentDocumentId(String(autoSaveDocument.documentId));
|
const portfolioData = get(portfolio);
|
||||||
|
const orderedIds = portfolioData.documents.map((doc) => doc.id);
|
||||||
|
|
||||||
|
await databaseUpdate<PersistedState>("state", (old) => {
|
||||||
|
const state = old || emptyPersistedState();
|
||||||
|
|
||||||
|
// Update (or add) the document info entry
|
||||||
|
const entry = createDocumentInfo(documentId, details.name, details.is_saved);
|
||||||
|
const existingIndex = state.documents.findIndex((doc) => doc.id === documentId);
|
||||||
|
if (existingIndex !== -1) {
|
||||||
|
state.documents[existingIndex] = entry;
|
||||||
|
} else {
|
||||||
|
state.documents.push(entry);
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
state.current_document = documentId;
|
||||||
|
state.documents = reorderDocuments(state.documents, orderedIds);
|
||||||
|
return state;
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function removeDocument(id: string, portfolio: PortfolioStore) {
|
export async function removeDocument(id: string, portfolio: PortfolioStore) {
|
||||||
await databaseUpdate<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents", (old) => {
|
const documentId = BigInt(id);
|
||||||
|
|
||||||
|
// Remove content from the documents store
|
||||||
|
await databaseUpdate<Record<string, string>>("documents", (old) => {
|
||||||
const documents = old || {};
|
const documents = old || {};
|
||||||
delete documents[id];
|
delete documents[id];
|
||||||
return documents;
|
return documents;
|
||||||
});
|
});
|
||||||
|
|
||||||
await databaseUpdate<string[]>("documents_tab_order", (old) => {
|
// Update state: remove the entry and update current_document
|
||||||
const order = old || [];
|
const portfolioData = get(portfolio);
|
||||||
return order.filter((docId) => docId !== id);
|
const documentCount = portfolioData.documents.length;
|
||||||
});
|
|
||||||
|
|
||||||
const documentCount = get(portfolio).documents.length;
|
await databaseUpdate<PersistedState>("state", (old) => {
|
||||||
if (documentCount > 0) {
|
const state: PersistedState = old || emptyPersistedState();
|
||||||
const documentIndex = get(portfolio).activeDocumentIndex;
|
state.documents = state.documents.filter((doc) => doc.id !== documentId);
|
||||||
const documentId = String(get(portfolio).documents[documentIndex].id);
|
|
||||||
|
|
||||||
const tabOrder = (await databaseGet<string[]>("documents_tab_order")) || [];
|
if (state.current_document === documentId) {
|
||||||
if (tabOrder.includes(documentId)) {
|
// eslint-disable-next-line camelcase
|
||||||
await storeCurrentDocumentId(documentId);
|
state.current_document = documentCount > 0 ? portfolioData.documents[portfolioData.activeDocumentIndex].id : undefined;
|
||||||
}
|
|
||||||
} else {
|
|
||||||
await databaseDelete("current_document_id");
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function loadFirstDocument(editor: EditorWrapper) {
|
return state;
|
||||||
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents");
|
|
||||||
|
|
||||||
// TODO: Eventually remove this document upgrade code
|
|
||||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if the browser is storing the old format as strings
|
|
||||||
if (previouslySavedDocuments) {
|
|
||||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
|
||||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const documentOrder = await databaseGet<string[]>("documents_tab_order");
|
export async function loadDocuments(editor: EditorWrapper) {
|
||||||
const currentDocumentIdString = await databaseGet<string>("current_document_id");
|
await migrateToNewFormat();
|
||||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
await garbageCollectDocuments();
|
||||||
if (!previouslySavedDocuments || !documentOrder) return;
|
|
||||||
|
|
||||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
const state = await databaseGet<PersistedState>("state");
|
||||||
|
const documentContents = await databaseGet<Record<string, string>>("documents");
|
||||||
|
if (!state || !documentContents || state.documents.length === 0) return;
|
||||||
|
|
||||||
if (currentDocumentId !== undefined && String(currentDocumentId) in previouslySavedDocuments) {
|
// Find the current document (or fall back to the last document in the list)
|
||||||
const doc = previouslySavedDocuments[String(currentDocumentId)];
|
const currentId = state.current_document;
|
||||||
editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
const currentEntry = currentId !== undefined ? state.documents.find((doc) => doc.id === currentId) : undefined;
|
||||||
editor.selectDocument(currentDocumentId);
|
const current = currentEntry || state.documents[state.documents.length - 1];
|
||||||
} else {
|
|
||||||
const len = orderedSavedDocuments.length;
|
|
||||||
if (len > 0) {
|
|
||||||
const doc = orderedSavedDocuments[len - 1];
|
|
||||||
editor.openAutoSavedDocument(doc.documentId, doc.details.name, doc.details.isSaved, doc.document, false);
|
|
||||||
editor.selectDocument(doc.documentId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function loadRestDocuments(editor: EditorWrapper) {
|
// Open all documents in persisted tab order, then select the current one
|
||||||
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents");
|
state.documents.forEach((entry) => {
|
||||||
|
const content = documentContents[String(entry.id)];
|
||||||
|
if (content === undefined) return;
|
||||||
|
|
||||||
// TODO: Eventually remove this document upgrade code
|
editor.openAutoSavedDocument(entry.id, entry.name, entry.is_saved, content, false);
|
||||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
|
||||||
if (previouslySavedDocuments) {
|
|
||||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
|
||||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
|
||||||
});
|
});
|
||||||
}
|
|
||||||
|
|
||||||
const documentOrder = await databaseGet<string[]>("documents_tab_order");
|
editor.selectDocument(current.id);
|
||||||
const currentDocumentIdString = await databaseGet<string>("current_document_id");
|
|
||||||
const currentDocumentId = currentDocumentIdString ? BigInt(currentDocumentIdString) : undefined;
|
|
||||||
if (!previouslySavedDocuments || !documentOrder) return;
|
|
||||||
|
|
||||||
const orderedSavedDocuments = documentOrder.flatMap((id) => (previouslySavedDocuments[id] ? [previouslySavedDocuments[id]] : []));
|
|
||||||
|
|
||||||
const currentIndex = currentDocumentId !== undefined ? orderedSavedDocuments.findIndex((doc) => doc.documentId === currentDocumentId) : -1;
|
|
||||||
|
|
||||||
// Open documents in order around the current document, placing earlier ones before it and later ones after
|
|
||||||
if (currentIndex !== -1 && currentDocumentId !== undefined) {
|
|
||||||
for (let i = currentIndex - 1; i >= 0; i--) {
|
|
||||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
|
||||||
const { name, isSaved } = details;
|
|
||||||
editor.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
|
||||||
}
|
|
||||||
for (let i = currentIndex + 1; i < orderedSavedDocuments.length; i++) {
|
|
||||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
|
||||||
const { name, isSaved } = details;
|
|
||||||
editor.openAutoSavedDocument(documentId, name, isSaved, document, false);
|
|
||||||
}
|
|
||||||
|
|
||||||
editor.selectDocument(currentDocumentId);
|
|
||||||
}
|
|
||||||
// No valid current document: open all remaining documents and select the last one
|
|
||||||
else {
|
|
||||||
const length = orderedSavedDocuments.length;
|
|
||||||
|
|
||||||
for (let i = length - 2; i >= 0; i--) {
|
|
||||||
const { documentId, document, details } = orderedSavedDocuments[i];
|
|
||||||
const { name, isSaved } = details;
|
|
||||||
editor.openAutoSavedDocument(documentId, name, isSaved, document, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (length > 0) editor.selectDocument(orderedSavedDocuments[length - 1].documentId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function saveActiveDocument(documentId: bigint) {
|
export async function saveActiveDocument(documentId: bigint) {
|
||||||
const previouslySavedDocuments = await databaseGet<Record<string, MessageBody<"TriggerPersistenceWriteDocument">>>("documents");
|
await databaseUpdate<PersistedState>("state", (old) => {
|
||||||
|
const state: PersistedState = old || emptyPersistedState();
|
||||||
|
|
||||||
const documentIdString = String(documentId);
|
const exists = state.documents.some((doc) => doc.id === documentId);
|
||||||
|
// eslint-disable-next-line camelcase
|
||||||
|
if (exists) state.current_document = documentId;
|
||||||
|
|
||||||
// TODO: Eventually remove this document upgrade code
|
return state;
|
||||||
// Migrate TriggerPersistenceWriteDocument.documentId from string to bigint if needed
|
|
||||||
if (previouslySavedDocuments) {
|
|
||||||
Object.values(previouslySavedDocuments).forEach((doc) => {
|
|
||||||
if (typeof doc.documentId === "string") doc.documentId = BigInt(doc.documentId);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!previouslySavedDocuments) return;
|
|
||||||
if (documentIdString in previouslySavedDocuments) {
|
|
||||||
await storeCurrentDocumentId(documentIdString);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function saveEditorPreferences(preferences: unknown) {
|
export async function saveEditorPreferences(preferences: unknown) {
|
||||||
await databaseSet("preferences", preferences);
|
await databaseSet("preferences", preferences);
|
||||||
}
|
}
|
||||||
|
|
@ -170,12 +163,118 @@ export async function loadWorkspaceLayout(editor: EditorWrapper) {
|
||||||
if (layout) editor.loadWorkspaceLayout(layout);
|
if (layout) editor.loadWorkspaceLayout(layout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove orphaned entries from the "documents" content store that have no corresponding entry in "state"
|
||||||
|
async function garbageCollectDocuments() {
|
||||||
|
const state = await databaseGet<PersistedState>("state");
|
||||||
|
const documentContents = await databaseGet<Record<string, string>>("documents");
|
||||||
|
if (!documentContents) return;
|
||||||
|
|
||||||
|
const validIds = new Set(state ? state.documents.map((doc) => String(doc.id)) : []);
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
Object.keys(documentContents).forEach((key) => {
|
||||||
|
if (!validIds.has(key)) {
|
||||||
|
delete documentContents[key];
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (changed) await databaseSet("documents", documentContents);
|
||||||
|
}
|
||||||
|
|
||||||
export async function wipeDocuments() {
|
export async function wipeDocuments() {
|
||||||
|
await databaseDelete("state");
|
||||||
|
await databaseDelete("documents");
|
||||||
|
|
||||||
|
await wipeOldFormat();
|
||||||
|
}
|
||||||
|
|
||||||
|
// =========================
|
||||||
|
// Migration from old format
|
||||||
|
// =========================
|
||||||
|
|
||||||
|
// TODO: Eventually remove this document upgrade code
|
||||||
|
async function wipeOldFormat() {
|
||||||
await databaseDelete("documents_tab_order");
|
await databaseDelete("documents_tab_order");
|
||||||
await databaseDelete("current_document_id");
|
await databaseDelete("current_document_id");
|
||||||
await databaseDelete("documents");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TODO: Eventually remove this document upgrade code
|
||||||
|
async function migrateToNewFormat() {
|
||||||
|
// Detect the old format by checking for the existence of the "documents_tab_order" key
|
||||||
|
const oldTabOrder = await databaseGet<string[]>("documents_tab_order");
|
||||||
|
if (oldTabOrder === undefined) return;
|
||||||
|
|
||||||
|
const oldDocuments = await databaseGet<Record<string, unknown>>("documents");
|
||||||
|
|
||||||
|
// Build the new "state" and "documents" from the old format
|
||||||
|
const newDocumentContents: Record<string, string> = {};
|
||||||
|
const newDocumentInfos: PersistedDocumentInfo[] = [];
|
||||||
|
|
||||||
|
if (oldDocuments) {
|
||||||
|
Object.values(oldDocuments).forEach((value) => {
|
||||||
|
const oldEntry: unknown = value;
|
||||||
|
if (typeof oldEntry !== "object" || oldEntry === null) return;
|
||||||
|
if (!("documentId" in oldEntry) || !("document" in oldEntry) || !("details" in oldEntry)) return;
|
||||||
|
|
||||||
|
// Extract the document ID, handling bigint, number, and string formats
|
||||||
|
let id: bigint;
|
||||||
|
if (typeof oldEntry.documentId === "bigint") {
|
||||||
|
id = oldEntry.documentId;
|
||||||
|
} else if (typeof oldEntry.documentId === "number") {
|
||||||
|
id = BigInt(oldEntry.documentId);
|
||||||
|
} else if (typeof oldEntry.documentId === "string") {
|
||||||
|
id = BigInt(oldEntry.documentId);
|
||||||
|
} else {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the document content
|
||||||
|
if (typeof oldEntry.document !== "string") return;
|
||||||
|
newDocumentContents[String(id)] = oldEntry.document;
|
||||||
|
|
||||||
|
// Extract document details, handling camelCase from the old shipped format
|
||||||
|
const details: unknown = oldEntry.details;
|
||||||
|
if (typeof details !== "object" || details === null) return;
|
||||||
|
|
||||||
|
let name = "";
|
||||||
|
if ("name" in details && typeof details.name === "string") name = details.name;
|
||||||
|
|
||||||
|
const isSaved = extractIsSavedFromUnknown(details);
|
||||||
|
|
||||||
|
newDocumentInfos.push(createDocumentInfo(id, name, isSaved));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const newState = emptyPersistedState();
|
||||||
|
newState.documents = newDocumentInfos;
|
||||||
|
|
||||||
|
// Write the new format
|
||||||
|
await databaseSet("state", newState);
|
||||||
|
await databaseSet("documents", newDocumentContents);
|
||||||
|
|
||||||
|
// Delete old keys
|
||||||
|
await databaseDelete("documents_tab_order");
|
||||||
|
await databaseDelete("current_document_id");
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Eventually remove this document upgrade code
|
||||||
|
function extractIsSavedFromUnknown(details: unknown): boolean {
|
||||||
|
if (typeof details !== "object" || details === null) return false;
|
||||||
|
|
||||||
|
// Old camelCase format
|
||||||
|
if ("isSaved" in details) return Boolean(details.isSaved);
|
||||||
|
|
||||||
|
// New snake_case format
|
||||||
|
if ("is_saved" in details) return Boolean(details.is_saved);
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// =================
|
||||||
|
// IndexedDB helpers
|
||||||
|
// =================
|
||||||
|
|
||||||
function databaseOpen(): Promise<IDBDatabase> {
|
function databaseOpen(): Promise<IDBDatabase> {
|
||||||
return new Promise((resolve, reject) => {
|
return new Promise((resolve, reject) => {
|
||||||
const request = indexedDB.open(PERSISTENCE_DB, 1);
|
const request = indexedDB.open(PERSISTENCE_DB, 1);
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue