Merge branch 'main' into pr/zeedif/1015

This commit is contained in:
vorotamoroz
2026-07-31 07:14:14 +01:00
26 changed files with 4656 additions and 805 deletions
+9 -5
View File
@@ -37,9 +37,9 @@ The Obsidian host injects the screen wake-lock manager from the `octagonal-wheel
Finite replication enters both counts only after readiness checks have succeeded and leaves them after `openReplication(..., continuous: false, ...)` settles. A successful completion has reached the latest sequence in that operation's scope and is therefore an authoritative quiescence boundary for chunk retrieval. A failed operation does not prove latest state, but can no longer deliver documents from that attempt. Failure handling runs afterwards so a mismatch or recovery dialogue does not retain the activity. This includes the direct start-up synchronisation path as well as manual, event-driven, and periodic calls through `ReplicationService`. The unbounded continuous channel does not enter either boundary, but its finite initial pull-only catch-up does; the one-shot parameter fallback chain remains inside that boundary.
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. A mobile device can have only a short opportunity to obtain remote data, while applying a large downloaded batch to the Vault is durable, offline-capable work which can continue or resume later. The replication-result queue and its recovery snapshot therefore remain outside `boundedRemoteActivityCount`. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application.
Delivery into the local database and application to the Obsidian Vault are deliberately separate lifetimes. Finite remote activity ends when the transfer operation settles, even when the `📥` queue still contains documents awaiting Vault application. The Obsidian host tracks that queue through a separate `boundedLocalApplicationActivityCount`, so local Vault writes do not increment either remote-activity count or keep the `📲` indicator visible.
This separation also keeps the activity indicators truthful: `📲` describes a finite remote operation and must not remain active solely because local Vault writes are pending. The existing replication-result count continues to describe that local queue. If a future feature offers screen-awake protection while applying downloaded documents, it must use a separately typed local-application activity or power-policy boundary, preserve the current behaviour by default, and avoid incrementing either remote-activity count.
Applying downloaded document changes enters one local-application boundary from the first queued document until the queue and its in-progress set are both empty. The final empty recovery snapshot is attempted before the boundary settles; snapshot failure is logged and still releases the boundary. Additional batches share the existing boundary. Suspending replication-result processing releases it, and resuming reacquires it while work remains. The same host activity runner supplies best-effort Wake Lock protection, while the visibility lifecycle waits for both remote and local bounded activity to finish.
Manual P2P commands which bypass `ReplicationService` enter the broad boundary. Direct P2P pull and push entry points are therefore both protected as finite remote work, covering the Obsidian panes, CLI, and Webapp. A pull or bidirectional synchronisation also enters the narrower finite-replication boundary because it can place documents in the local database. A push-only request remains broad-only: it cannot satisfy a local missing-chunk read and must not present itself as a delivery source. Automatic synchronisation on peer discovery, a pull requested by a remote peer, and a watched pull following a peer progress notification enter both boundaries because each can deliver local documents. A normal P2P peer-selection dialogue represents one broad finite session: it remains inside the boundary while waiting for a peer and while the person may perform repeated synchronisations, then settles when the dialogue closes and any in-flight synchronisation has finished. Closing without synchronising returns a failed result and releases the boundary. The 'Start Sync & Close' action completes its synchronisation before closing. This deliberately protects peer discovery and selection, because display sleep can interrupt discovery or connection establishment and require the person to start detection again. It may therefore retain a Wake Lock longer than the network transfer alone. A transfer performed inside that session temporarily adds a nested activity; the count remains a logical-operation count rather than a connection total.
@@ -76,6 +76,8 @@ P2P does not yet contribute to the physical-request count because it does not ha
The platform activity runner remains injected. Common library and headless consumers can omit it while retaining the same bounded activity count and operation semantics.
The Obsidian-specific `ObsidianReplicatorService` owns `boundedLocalApplicationActivityCount` and reuses the injected activity runner. It is deliberately absent from the common service contract: CLI and Webapp processing continue directly, while the Obsidian host uses the count for Wake Lock and visibility-lifecycle policy without changing remote-operation reporting.
## Non-Goals
- Do not count continuous replication as a bounded activity.
@@ -85,11 +87,11 @@ The platform activity runner remains injected. Common library and headless consu
- Do not guarantee protection against operating-system suspension, closing a laptop lid, forced termination, network loss, or a user-initiated sleep action.
- Do not add a lifecycle timeout which would abort an unusually slow rebuild. A genuinely stalled operation may postpone LiveSync's visibility suspension until it settles, but the platform may still suspend or terminate background work.
- Do not broaden `keepReplicationActiveInBackground`; it remains an opt-in desktop policy for continuous and periodic operation after finite work has ended.
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in this boundary. They are offline-capable and require a separate decision if activity reporting or power policy is added later.
- Do not include offline scans, unrelated local storage reflection, or the durable replication-result queue in either remote-activity count. The queue uses its separate local-application boundary.
## Verification
Before changing the transfer/application separation, add a deterministic regression scenario which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, persists the queued state, and resumes Vault application after suspension or restart. An optional local-application Wake Lock feature requires its own enabled and disabled cases; existing remote-operation E2E is not evidence for that separate policy.
Before changing the transfer/application separation, keep deterministic coverage which leaves downloaded documents queued after finite transfer settles, verifies that remote activity has ended, and retains the separate local-application boundary until Vault application and the final recovery snapshot settle.
Unit tests cover:
@@ -106,6 +108,8 @@ Unit tests cover:
- remote chunk fetching remaining inside the shared boundary from synchronous queue acceptance through local persistence and terminal notification;
- missing-chunk waiters rechecking local storage when observed per-identifier claims and finite replication have settled;
- the finite-replication count excluding other bounded work;
- replicated document application sharing one local boundary, settling after the final recovery snapshot, and releasing around processing suspension;
- local application activity leaving both remote-activity counts unchanged;
- standard, fast, remote, and combined rebuild activity boundaries;
- Rebuilder-owned confirmation and completion dialogues remaining outside rebuild activity;
- fallback from fast fetch avoiding a nested activity boundary;
@@ -125,5 +129,5 @@ The exact Fancy Kit screen wake-lock behaviour is covered by its package and Har
- One finite activity definition drives Wake Lock, lifecycle protection, and status UI without coupling common library code to Obsidian or browser globals.
- Callers can observe accurate logical activity even in CLI and Webapp hosts which do not inject a Wake Lock implementation.
- Rebuild operations now retain Wake Lock and lifecycle protection across their longest interruption-sensitive phases without retaining them for Rebuilder-owned pre-operation or completion dialogues. Post-reset P2P discovery and selection remain protected as an intentional part of completing the rebuild.
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, allowing transfer and offline application to follow different mobile lifetimes without presenting local writes as communication.
- Downloaded documents may remain in the durable Vault-application queue after remote activity has ended, while a separate local-application boundary retains best-effort Wake Lock and lifecycle protection without presenting local writes as communication.
- Users can now distinguish the lifetime of a finite remote operation from approximate request activity within it.
+7 -7
View File
@@ -32,7 +32,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.52",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
@@ -11532,9 +11532,9 @@
"license": "MIT"
},
"node_modules/octagonal-wheels": {
"version": "0.1.51",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.51.tgz",
"integrity": "sha512-KTlfqKPjobHJg/t3A539srnFf+VHr1aXkHSmsNDDpiI5UFC7FamZ95dWpJfGE2EI/HULR5hveQDgkazmz8SAcg==",
"version": "0.1.52",
"resolved": "https://registry.npmjs.org/octagonal-wheels/-/octagonal-wheels-0.1.52.tgz",
"integrity": "sha512-9WJN2UveNh90Op1S07cIso1WyNrQbO/unibDLfUGnpomIcU4g6F+p8reZHW0Ed8sGzKF5qGnQp991Y9MB1TwNg==",
"license": "MIT",
"dependencies": {
"idb": "^8.0.3"
@@ -15928,7 +15928,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.52",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
@@ -15951,7 +15951,7 @@
"name": "livesync-webapp",
"version": "1.0.1-webapp",
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
@@ -15963,7 +15963,7 @@
"src/apps/webpeer": {
"version": "1.0.1-webpeer",
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
+2 -1
View File
@@ -58,6 +58,7 @@
"test:e2e:obsidian:dialog-mounts": "tsx test/e2e-obsidian/scripts/dialog-mounts.ts",
"test:e2e:obsidian:conflict-dialog-policy": "tsx test/e2e-obsidian/scripts/conflict-dialog-policy.ts",
"test:e2e:obsidian:revision-repair": "tsx test/e2e-obsidian/scripts/revision-repair.ts",
"test:e2e:obsidian:document-history-nav": "tsx test/e2e-obsidian/scripts/document-history-nav.ts",
"test:e2e:obsidian:settings-ui": "tsx test/e2e-obsidian/scripts/settings-ui.ts",
"test:e2e:obsidian:review-harness": "tsx test/e2e-obsidian/scripts/review-harness.ts",
"test:e2e:obsidian:p2p-pane": "tsx test/e2e-obsidian/scripts/p2p-pane.ts",
@@ -182,7 +183,7 @@
"markdown-it": "^14.2.0",
"minimatch": "^10.2.5",
"obsidian": "^1.13.1",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.52",
"qrcode-generator": "^1.4.4",
"xxhash-wasm-102": "npm:xxhash-wasm@^1.0.2"
},
+1 -1
View File
@@ -37,7 +37,7 @@
"dependencies": {
"chokidar": "^4.0.0",
"minimatch": "^10.2.5",
"octagonal-wheels": "^0.1.51",
"octagonal-wheels": "^0.1.52",
"pouchdb-adapter-http": "^9.0.0",
"pouchdb-adapter-leveldb": "^9.0.0",
"pouchdb-core": "^9.0.0",
+1 -1
View File
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webapp/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2",
+1 -1
View File
@@ -15,7 +15,7 @@
"test:browser": "deno test -A --no-check --frozen --config ../../../test/browser-apps/deno.json --lock ../../../test/browser-apps/deno.lock ../../../test/browser-apps/webpeer/browser-smoke.test.ts"
},
"dependencies": {
"octagonal-wheels": "^0.1.51"
"octagonal-wheels": "^0.1.52"
},
"devDependencies": {
"eslint-plugin-svelte": "^3.19.0",
@@ -94,10 +94,8 @@ export const liveSyncProvisionalEnglishMessages = {
"🧩 Missing chunks: ${COUNT}": "🧩 Missing chunks: ${COUNT}",
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B":
"📦 DB: recorded ${RECORDED} B · decoded ${DECODED} B · Δsize ${DIFFERENCE} B",
"📦 DB: recorded ${RECORDED} B · decoded unavailable":
"📦 DB: recorded ${RECORDED} B · decoded unavailable",
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B":
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
"📦 DB: recorded ${RECORDED} B · decoded unavailable": "📦 DB: recorded ${RECORDED} B · decoded unavailable",
"📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B": "📁 Vault: ${VAULT} B · Δsize vs DB ${DIFFERENCE} B",
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})":
"🕒 DB ${DATABASE_TIME} · Vault ${VAULT_TIME} · Δtime ${DIFFERENCE} ms (${RELATION})",
"✅ Matches Vault": "✅ Matches Vault",
@@ -130,8 +128,7 @@ export const liveSyncProvisionalEnglishMessages = {
"More actions for revision ${REVISION}": "More actions for revision ${REVISION}",
"More actions for ${FILE}": "More actions for ${FILE}",
"Show revision history": "Show revision history",
"Store Vault file as a new local database document":
"Store Vault file as a new local database document",
"Store Vault file as a new local database document": "Store Vault file as a new local database document",
"Copy database information": "Copy database information",
"Recreate chunks for current Vault files": "Recreate chunks for current Vault files",
"Recreate chunks from the files currently present in this Vault. This cannot reconstruct unavailable historical or conflict content.":
@@ -140,8 +137,7 @@ export const liveSyncProvisionalEnglishMessages = {
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.":
"Resolve every conflict by modification time? This logically deletes every version except the newest one and cannot recover content which is already unavailable.",
"Resolve all conflicts by the newest version": "Resolve all conflicts by the newest version",
"Inspect conflicts and file/database differences":
"Inspect conflicts and file/database differences",
"Inspect conflicts and file/database differences": "Inspect conflicts and file/database differences",
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.":
"Scan every Vault file and live local-database revision for conflicts, missing chunks, and differences. Each result provides actions for the exact revision.",
"Begin inspection": "Begin inspection",
File diff suppressed because it is too large Load Diff
+488
View File
@@ -21,6 +21,7 @@
"↓: Overwrite Local": "↓: Sobrescribir local",
"⇅: Use newer": "⇅: Usar el más nuevo",
"+1 week": "+1 semana",
"> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Se han detectado los siguientes dispositivos conectados:\n${devices}",
"⚠️ Important Notice": "⚠️ Aviso importante",
"⚠️ Please Confirm the Following": "⚠️ Confirma lo siguiente",
"✔ SELECT": "✔ SELECCIONAR",
@@ -42,6 +43,7 @@
"Access Key ID": "ID de clave de acceso",
"Action": "Acción",
"Activate": "Activar",
"Active Remote Configuration": "Configuración remota activa",
"Add default patterns": "Añadir patrones predeterminados",
"Add new connection": "Añadir conexión",
"AddOn Module (ConfigSync) has not been loaded. This is very unexpected situation. Please report this issue.": "El módulo complementario (ConfigSync) no se ha cargado. Esta situación es muy inesperada. Informa de este problema.",
@@ -52,11 +54,15 @@
"After restarting, the database on this device will be rebuilt using data from the server. If there are any unsynchronised files in this vault, conflicts may occur with the server data.": "Tras reiniciar, la base de datos de este dispositivo se reconstruirá con los datos del servidor. Si hay archivos sin sincronizar en este vault, pueden producirse conflictos con los datos del servidor.",
"After that, synchronise to a brand new vault on each other device with the new remote one by one.": "Después, sincroniza con un vault totalmente nuevo en cada uno de los demás dispositivos, uno por uno, usando el nuevo remoto.",
"All checks passed successfully!": "¡Todas las comprobaciones se han superado correctamente!",
"All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": "Todos los dispositivos tienen el mismo valor de progreso (${progress}). Parece que están sincronizados, por lo que se puede continuar con la recolección de basura.",
"All the same or non-existent": "Todo igual o inexistente",
"Allow in session": "Permitir en esta sesión",
"Allow permanently": "Permitir permanentemente",
"Also, please note that if you are using Peer-to-Peer synchronization, this configuration will be used when you switch to other methods and connect to a remote server in the future.": "Además, si usas sincronización punto a punto, esta configuración se aplicará cuando en el futuro cambies a otros métodos y te conectes a un servidor remoto.",
"Always prompt merge conflicts": "Siempre preguntar en conflictos",
"Analyse": "Analizar",
"Analyse database usage": "Analizar el uso de la base de datos",
"Analyse database usage and generate a TSV report for diagnosis yourself. You can paste the generated report with any spreadsheet you like.": "Analiza el uso de la base de datos y genera un informe TSV para que puedas diagnosticarlo tú mismo. Puedes pegar el informe generado en la hoja de cálculo que prefieras.",
"Apply All Selected": "Aplicar todo lo seleccionado",
"Apply Latest Change if Conflicting": "Aplicar último cambio en conflictos",
"Apply preset configuration": "Aplicar configuración predefinida",
@@ -78,7 +84,10 @@
"Bucket Name": "Nombre del bucket",
"by resetting the remote, you will be informed on other devices.": "al restablecer el remoto, se te avisará en los demás dispositivos.",
"Cancel": "Cancelar",
"Cancel Garbage Collection": "Cancelar la recolección de basura",
"Changing the encryption algorithm will prevent access to any data previously encrypted with a different algorithm. Ensure that all your devices are configured to use the same algorithm to maintain access to your data.": "Cambiar el algoritmo de cifrado impedirá acceder a los datos cifrados previamente con otro algoritmo. Asegúrate de que todos tus dispositivos usen el mismo algoritmo para no perder el acceso a tus datos.",
"Changing this setting requires migrating existing data (a bit time may be taken) and restarting Obsidian. Please make sure to back up your data before proceeding.": "Cambiar este ajuste requiere migrar los datos existentes (puede tardar un poco) y reiniciar Obsidian. Asegúrate de hacer una copia de seguridad de tus datos antes de continuar.",
"Check": "Comprobar",
"Check and convert non-path-obfuscated files": "Comprobar y convertir archivos sin ofuscación de ruta",
"Check for documents that have not been converted to path-obfuscated IDs and convert them if necessary.": "Comprueba los documentos que aún no se hayan convertido a identificadores con ruta ofuscada y conviértelos si es necesario.",
"Checking connection... Please wait.": "Comprobando la conexión... Espera un momento.",
@@ -91,6 +100,10 @@
"Comma separated `.gitignore, .dockerignore`": "Separados por comas: `.gitignore, .dockerignore`",
"Command": "Comando",
"Communicating": "Comunicando",
"Compaction in progress on remote database...": "Compactación en curso en la base de datos remota...",
"Compaction on remote database completed successfully.": "La compactación de la base de datos remota se ha completado correctamente.",
"Compaction on remote database failed.": "La compactación de la base de datos remota ha fallado.",
"Compaction on remote database timed out.": "La compactación de la base de datos remota ha superado el tiempo de espera.",
"Compare file": "Comparar archivo",
"Compare the content of files between on local database and storage. If not matched, you will be asked which one you want to keep.": "Compara el contenido de los archivos entre la base de datos local y el almacenamiento. Si no coinciden, se te preguntará cuál deseas conservar.",
"Compatibility (Conflict Behaviour)": "Compatibilidad (comportamiento de conflictos)",
@@ -99,6 +112,7 @@
"Compatibility (Metadata)": "Compatibilidad (metadatos)",
"Compatibility (Remote Database)": "Compatibilidad (base de datos remota)",
"Compatibility (Trouble addressed)": "Compatibilidad (problemas corregidos)",
"Compute revisions for chunks": "Calcular revisiones para los chunks",
"Compute revisions for chunks (Previous behaviour)": "Calcular revisiones para chunks (comportamiento anterior)",
"Configuration": "Configuración",
"Configuration Encryption": "Cifrado de configuración",
@@ -118,6 +132,7 @@
"Continue to Peer-to-Peer only setup": "Continuar con la configuración solo Peer-to-Peer",
"Continue to S3/MinIO/R2 setup": "Continuar con la configuración de S3/MinIO/R2",
"Copy": "Copiar",
"Copy Report to clipboard": "Copiar el informe al portapapeles",
"CouchDB Configuration": "Configuración de CouchDB",
"CouchDB Connection Tweak": "Ajustes de conexión de CouchDB",
"Create P2P remote": "Crear remoto P2P",
@@ -128,6 +143,7 @@
"Customization Sync (Beta3)": "Sincronización de personalización (Beta3)",
"Data Compression": "Compresión de datos",
"Data to Copy": "Datos a copiar",
"Database -> Storage": "Base de datos -> Almacenamiento",
"Database Adapter": "Adaptador de base de datos",
"Database Name": "Nombre de la base de datos",
"Database suffix": "Sufijo de base de datos",
@@ -153,6 +169,7 @@
"desktop": "equipo de escritorio",
"Detected Peers": "Pares detectados",
"Developer": "Desarrollador",
"Device": "Dispositivo",
"device name": "nombre del dispositivo",
"Device name": "Nombre del dispositivo",
"Device name to identify the device. Please use shorter one for the stable peer detection, i.e., \"iphone-16\" or \"macbook-2021\".": "Nombre para identificar el dispositivo. Usa uno corto para que la detección de pares sea estable, por ejemplo «iphone-16» o «macbook-2021».",
@@ -160,6 +177,9 @@
"Device Setup Method": "Método de configuración del dispositivo",
"Devices:": "Dispositivos:",
"Diagnostic RTCPeerConnection is enabled": "El RTCPeerConnection de diagnóstico está habilitado",
"dialog.yourLanguageAvailable": "Self-hosted LiveSync tenía traducciones para tu idioma, así que se ha activado el ajuste %{Display language}.\n\nNota: no todos los mensajes están traducidos. ¡Esperamos tus contribuciones!\nNota 2: si abres una incidencia, **vuelve antes a %{lang-def}** y luego haz las capturas de pantalla y recoge los mensajes y registros. Puedes hacerlo desde el diálogo de ajustes.\n¡Que lo disfrutes!",
"dialog.yourLanguageAvailable.btnRevertToDefault": "Mantener %{lang-def}",
"dialog.yourLanguageAvailable.Title": " ¡Hay traducción disponible!",
"Diff": "Diferencias",
"Different": "Distinto",
"Disables all synchronization and restart.": "Desactiva toda la sincronización y reinicia la aplicación.",
@@ -172,6 +192,27 @@
"Do not keep metadata of deleted files.": "No conservar metadatos de archivos borrados",
"Do not split chunks in the background": "No dividir chunks en segundo plano",
"Do not use internal API": "No usar API interna",
"Doctor.Button.DismissThisVersion": "No, y no volver a preguntar hasta la próxima versión",
"Doctor.Button.Fix": "Corregirlo",
"Doctor.Button.FixButNoRebuild": "Corregirlo, pero sin reconstruir",
"Doctor.Button.No": "No",
"Doctor.Button.Skip": "Dejarlo como está",
"Doctor.Button.Yes": "Sí",
"Doctor.Dialogue.Main": "¡Hola! El Doctor de configuración se ha activado por ${activateReason}.\nPor desgracia, se han detectado algunos ajustes como posibles problemas.\nTranquilo: los resolveremos uno a uno.\n\nPara que lo sepas de antemano, te preguntaremos por los siguientes puntos.\n\n${issues}\n\n¿Empezamos?",
"Doctor.Dialogue.MainFix": "\n## ${name}\n\n| Actual | Ideal |\n|:---:|:---:|\n| ${current} | ${ideal} |\n\n**Nivel de recomendación:** ${level}\n\n### ¿Por qué se ha detectado?\n\n${reason}\n\n${note}\n\n¿Ajustarlo al valor ideal?",
"Doctor.Dialogue.Title": "Doctor de configuración de Self-hosted LiveSync",
"Doctor.Dialogue.TitleAlmostDone": "¡Casi hemos terminado!",
"Doctor.Dialogue.TitleFix": "Corregir el problema ${current}/${total}",
"Doctor.Level.Must": "Obligatorio",
"Doctor.Level.Necessary": "Necesario",
"Doctor.Level.Optional": "Opcional",
"Doctor.Level.Recommended": "Recomendado",
"Doctor.Message.NoIssues": "¡No se ha detectado ningún problema!",
"Doctor.Message.RebuildLocalRequired": "¡Atención! Hay que reconstruir la base de datos local para aplicar esto.",
"Doctor.Message.RebuildRequired": "¡Atención! Hay que reconstruir para aplicar esto.",
"Doctor.Message.SomeSkipped": "Hemos dejado algunos problemas sin resolver. ¿Quieres que te lo pregunte de nuevo en el próximo inicio?",
"Doctor.RULES.E2EE_V02500.REASON": "El cifrado de extremo a extremo es ahora más robusto y más rápido. Además, una nueva revisión del código reveló que el E2EE anterior estaba comprometido, por lo que conviene aplicarlo cuanto antes. Lamentamos de veras las molestias. Este ajuste no es compatible con versiones anteriores: todos los dispositivos sincronizados deben actualizarse a la v0.25.0 o superior. No es necesario reconstruir (los datos se convertirán al nuevo formato durante la transferencia), pero se recomienda hacerlo siempre que sea posible.",
"Document History": "Historial del documento",
"Duplicate": "Duplicar",
"Duplicate remote": "Duplicar remoto",
"E2EE Configuration": "Configuración de E2EE",
@@ -217,11 +258,15 @@
"Error during testAndFixSettings: ${reason}": "Error durante testAndFixSettings: ${reason}",
"Experimental Settings": "Ajustes experimentales",
"Export": "Exportar",
"Failed to connect to remote for compaction.": "No se pudo conectar al remoto para la compactación.",
"Failed to connect to remote for compaction. ${reason}": "No se pudo conectar al remoto para la compactación. ${reason}",
"Failed to connect to the server: ${reason}": "No se pudo conectar al servidor: ${reason}",
"Failed to connect to the server. Please check your settings.": "No se pudo conectar al servidor. Revisa tus ajustes.",
"Failed to connect to the signalling relay: ${reason}": "No se pudo conectar al relé de señalización: ${reason}",
"Failed to create replicator instance.": "No se pudo crear la instancia del replicador.",
"Failed to parse Setup-URI.": "No se pudo interpretar el Setup-URI.",
"Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": "No se pudo iniciar la replicación puntual previa a la recolección de basura. Recolección de basura cancelada.",
"Failed to start replication after Garbage Collection.": "No se pudo iniciar la replicación después de la recolección de basura.",
"Failed:": "Fallidas:",
"Fetch": "Obtener",
"Fetch chunks on demand": "Obtener chunks bajo demanda",
@@ -231,6 +276,7 @@
"Fetching status...": "Obteniendo el estado...",
"File integrity": "Integridad de archivos",
"File to resolve conflict": "Archivo para resolver el conflicto",
"File to view History": "Archivo cuyo historial se va a ver",
"Filename": "Nombre de archivo",
"Final Confirmation: Overwrite Server Data with This Device's Files": "Confirmación final: sobrescribir los datos del servidor con los archivos de este dispositivo",
"First, please select the option that best describes your current situation.": "Primero, seleccione la opción que describa mejor su situación actual。",
@@ -242,7 +288,13 @@
"Forces the file to be synced when opened.": "Forzar sincronización al abrir archivo",
"Fresh Start Wipe": "Borrado para reinicio completo",
"Furthermore, if conflicts are already present in the server data, they will be synchronised to this device as they are, and you will need to resolve them locally.": "Además, si ya hay conflictos en los datos del servidor, se sincronizarán tal cual a este dispositivo y tendrás que resolverlos localmente.",
"Garbage Collection cancelled by user.": "Recolección de basura cancelada por el usuario.",
"Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": "Recolección de basura completada. Chunks eliminados: ${deletedChunks} / ${totalChunks}. Tiempo empleado: ${seconds} segundos.",
"Garbage Collection Confirmation": "Confirmación de la recolección de basura",
"Garbage Collection V3 (Beta)": "Recolección de basura V3 (Beta)",
"Garbage Collection: Found ${unusedChunks} unused chunks to delete.": "Recolección de basura: se han encontrado ${unusedChunks} chunks sin usar para eliminar.",
"Garbage Collection: Scanned ${scanned} / ~${docCount}": "Recolección de basura: analizados ${scanned} / ~${docCount}",
"Garbage Collection: Scanning completed. Total chunks: ${totalChunks}, Used chunks: ${usedChunks}": "Recolección de basura: análisis completado. Chunks totales: ${totalChunks}, chunks en uso: ${usedChunks}",
"Gathering information...": "Recopilando información...",
"Generate Random ID": "Generar un ID aleatorio",
"Group ID": "ID de grupo",
@@ -250,8 +302,10 @@
"Have you created a backup before proceeding?": "¿Has creado una copia de seguridad antes de continuar?",
"Hidden file synchronization have been temporarily disabled. Please enable them after the fetching, if you need them.": "La sincronización de archivos ocultos se ha desactivado temporalmente. Vuelve a activarla después de la obtención si la necesitas.",
"Hidden Files": "Archivos ocultos",
"Hide completely": "Ocultar por completo",
"Hide not applicable items": "Ocultar elementos no aplicables",
"Higher (${local} > ${remote})": "Superior (${local} > ${remote})",
"Highlight diff": "Resaltar las diferencias",
"How to display network errors when the sync server is unreachable.": "Cómo mostrar los errores de red cuando el servidor de sincronización no está disponible.",
"How would you like to configure the connection to your server?": "¿Cómo desea configurar la conexión con su servidor?",
"However, This should not be enabled if you want to increase your secrecy more.": "Sin embargo, esto no debería habilitarse si quieres aumentar aún más tu privacidad.",
@@ -283,10 +337,12 @@
"If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the CouchDB server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions.": "Si no puedes evitar problemas de CORS, prueba esta opción. Usa la API interna de Obsidian para comunicarse con el servidor CouchDB. No cumple los estándares web, pero funciona. Ten en cuenta que podría dejar de funcionar en versiones futuras de Obsidian.",
"If you cannot avoid CORS issues, you might want to try this option. It uses Obsidian's internal API to communicate with the S3 server. Not compliant with web standards, but works. Note that this might break in future Obsidian versions.": "Si no puedes evitar problemas de CORS, prueba esta opción. Usa la API interna de Obsidian para comunicarse con el servidor S3. No cumple los estándares web, pero funciona. Ten en cuenta que podría dejar de funcionar en versiones futuras de Obsidian.",
"If you have unsynchronised changes in your Vault on this device, they will likely diverge from the server's versions after the reset. This may result in a large number of file conflicts.": "Si tienes cambios sin sincronizar en el Vault de este dispositivo, es probable que divergan de las versiones del servidor tras el restablecimiento. Esto puede provocar un gran número de conflictos de archivos.",
"If you reached the payload size limit when using IBM Cloudant, please decrease batch size and batch limit to a lower value.": "Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, reduce el tamaño de lote y el límite de lote.",
"If you understand the risks and still wish to proceed, select so.": "Si entiendes los riesgos y aun así quieres continuar, indícalo.",
"If you want to store the data in a specific folder within the bucket, you can specify a folder prefix here. Otherwise, leave it blank to store data at the root of the bucket.": "Si quieres guardar los datos en una carpeta concreta del bucket, indica aquí un prefijo de carpeta. Si no, déjalo vacío para guardarlos en la raíz del bucket.",
"If you want to use `LiveSync`, you should broadcast changes. All `watching` peers which detects this will start the replication for fetching.": "Si quieres usar `LiveSync`, debes difundir los cambios. Todos los pares que estén `observando` y lo detecten iniciarán la replicación para obtenerlos.",
"Ignore": "Ignorar",
"Ignore and Proceed": "Ignorar y continuar",
"Ignore files": "Archivos a ignorar",
"Ignore patterns": "Patrones de exclusión",
"Import connection": "Importar conexión",
@@ -297,6 +353,8 @@
"Incubate Chunks in Document": "Incubar chunks en documento",
"Initial Action": "Acción inicial",
"Initialise all journal history, On the next sync, every item will be received and sent.": "Restablece todo el historial del diario. En la próxima sincronización se recibirán y enviarán todos los elementos.",
"Initialise journal received history. On the next sync, every item except this device sent will be downloaded again.": "Inicializa el historial de recepción del diario. En la próxima sincronización se volverán a descargar todos los elementos salvo los enviados por este dispositivo.",
"Initialise journal sent history. On the next sync, every item except this device received will be sent again.": "Inicializa el historial de envío del diario. En la próxima sincronización se volverán a enviar todos los elementos salvo los recibidos por este dispositivo.",
"Interval (sec)": "Intervalo (segundos)",
"INVERTED": "INVERTIDO",
"Issue detection log:": "Registro de detección de problemas:",
@@ -308,11 +366,22 @@
"JWT Key": "Clave JWT",
"JWT Key ID (kid)": "ID de clave JWT (kid)",
"JWT Subject (sub)": "Sujeto JWT (sub)",
"K.exp": "Experimental",
"K.long_p2p_sync": "%{title_p2p_sync}",
"K.P2P": "%{Peer} a %{Peer}",
"K.Peer": "par",
"K.ScanCustomization": "Buscar personalizaciones",
"K.short_p2p_sync": "Sincronización P2P",
"K.title_p2p_sync": "Sincronización punto a punto",
"Keep empty folder": "Mantener carpetas vacías",
"lang_def": "Predeterminado",
"lang-de": "Alemán",
"lang-def": "%{lang_def}",
"lang-es": "Español",
"lang-fr": "Français",
"lang-he": "Hebreo",
"lang-ja": "Japonés",
"lang-ko": "Coreano",
"lang-ru": "Ruso",
"lang-zh": "Chino simplificado",
"lang-zh-tw": "Chino tradicional",
@@ -331,6 +400,7 @@
"liveSyncReplicator.liveSyncBegin": "Inicio de LiveSync...",
"liveSyncReplicator.lockRemoteDb": "Bloquear base de datos remota para prevenir corrupción de datos",
"liveSyncReplicator.markDeviceResolved": "Marcar este dispositivo como 'resuelto'.",
"liveSyncReplicator.mismatchedTweakDetected": "Se han detectado discrepancias en la configuración entre dispositivos. Ejecutar una replicación manual intentará resolverlo.",
"liveSyncReplicator.oneShotSyncBegin": "Inicio de sincronización OneShot... (${syncMode})",
"liveSyncReplicator.remoteDbCorrupted": "La base de datos remota es más nueva o está dañada, asegúrese de tener la última versión de self-hosted-livesync instalada",
"liveSyncReplicator.remoteDbCreatedOrConnected": "Base de datos remota creada o conectada",
@@ -369,6 +439,7 @@
"Memory cache size (by total items)": "Tamaño caché memoria (por ítems)",
"Merge": "Fusionar",
"Minimum delay for batch database updating": "Retraso mínimo para actualización por lotes",
"Minimum interval for syncing": "Intervalo mínimo de sincronización",
"Mixed": "Mixto",
"moduleCheckRemoteSize.logCheckingStorageSizes": "Comprobando tamaños de almacenamiento",
"moduleCheckRemoteSize.logCurrentStorageSize": "Tamaño del almacenamiento remoto: ${measuredSize}",
@@ -377,6 +448,8 @@
"moduleCheckRemoteSize.msgConfirmRebuild": "Esto puede llevar un poco de tiempo. ¿Realmente quieres reconstruir todo ahora?",
"moduleCheckRemoteSize.msgDatabaseGrowing": "**¡Tu base de datos está creciendo!** Pero no te preocupes, podemos abordarlo ahora. El tiempo antes de quedarse sin espacio en el almacenamiento remoto.\n\n| Tamaño medido | Tamaño configurado |\n| --- | --- |\n| ${estimatedSize} | ${maxSize} |\n\n> [!MORE]-\n> Si lo has estado utilizando durante muchos años, puede haber fragmentos no referenciados - es decir, basura - acumulándose en la base de datos. Por lo tanto, recomendamos reconstruir todo. Probablemente se volverá mucho más pequeño.\n>\n> Si el volumen de tu bóveda simplemente está aumentando, es mejor reconstruir todo después de organizar los archivos. Self-hosted LiveSync no elimina los datos reales incluso si los eliminas para acelerar el proceso. Está aproximadamente [documentado](https://github.com/vrtmrz/obsidian-livesync/blob/main/docs/tech_info.md).\n>\n> Si no te importa el aumento, puedes aumentar el límite de notificación en 100 MB. Este es el caso si lo estás ejecutando en tu propio servidor. Sin embargo, es mejor reconstruir todo de vez en cuando.\n>\n\n> [!WARNING]\n> Si realizas la reconstrucción completa, asegúrate de que todos los dispositivos estén sincronizados. El complemento fusionará tanto como sea posible, sin embargo.\n",
"moduleCheckRemoteSize.msgSetDBCapacity": "Podemos configurar una advertencia de capacidad máxima de base de datos, **para tomar medidas antes de quedarse sin espacio en el almacenamiento remoto**.\n¿Quieres habilitar esto?\n\n> [!MORE]-\n> - 0: No advertir sobre el tamaño del almacenamiento.\n> Esto es recomendado si tienes suficiente espacio en el almacenamiento remoto, especialmente si lo tienes autoalojado. Y puedes comprobar el tamaño del almacenamiento y reconstruir manualmente.\n> - 800: Advertir si el tamaño del almacenamiento remoto supera los 800 MB.\n> Esto es recomendado si estás usando fly.io con un límite de 1 GB o IBM Cloudant.\n> - 2000: Advertir si el tamaño del almacenamiento remoto supera los 2 GB.\n\nSi hemos alcanzado el límite, se nos pedirá que aumentemos el límite paso a paso.\n",
"moduleCheckRemoteSize.noticeExceeded": "El tamaño del almacenamiento remoto es de ${measuredSize}, por encima del umbral de aviso configurado de ${notifySize}. {HERE}",
"moduleCheckRemoteSize.noticeNotConfigured": "Los avisos sobre el tamaño del almacenamiento remoto no están configurados. {HERE}",
"moduleCheckRemoteSize.option2GB": "2GB (Estándar)",
"moduleCheckRemoteSize.option800MB": "800MB (Cloudant, fly.io)",
"moduleCheckRemoteSize.optionAskMeLater": "Pregúntame más tarde",
@@ -384,6 +457,7 @@
"moduleCheckRemoteSize.optionIncreaseLimit": "aumentar a ${newMax}MB",
"moduleCheckRemoteSize.optionNoWarn": "No, nunca advertir por favor",
"moduleCheckRemoteSize.optionRebuildAll": "Reconstruir todo ahora",
"moduleCheckRemoteSize.optionReview": "Revisar las opciones",
"moduleCheckRemoteSize.titleDatabaseSizeLimitExceeded": "El tamaño del almacenamiento remoto superó el límite",
"moduleCheckRemoteSize.titleDatabaseSizeNotify": "Configuración de notificación de tamaño de base de datos",
"moduleInputUIObsidian.defaultTitleConfirmation": "Confirmación",
@@ -406,6 +480,18 @@
"moduleLocalDatabase.logWaitingForReady": "Esperando a que la base de datos esté lista...",
"moduleLog.showLog": "Mostrar registro",
"moduleMigration.docUri": "https://github.com/vrtmrz/obsidian-livesync/blob/main/README_ES.md#how-to-use",
"moduleMigration.fix0256.buttons.checkItLater": "Comprobarlo más tarde",
"moduleMigration.fix0256.buttons.DismissForever": "Ya lo he corregido, no volver a preguntar",
"moduleMigration.fix0256.buttons.fix": "Corregir",
"moduleMigration.fix0256.message": "Debido a un error reciente (en la v0.25.6), puede que algunos archivos no se hayan guardado correctamente en la base de datos de sincronización.\nHemos analizado tus archivos y hemos encontrado algunos que hay que corregir.\n\n**Archivos que se pueden corregir:**\n\n${files}\n\nEstos archivos tienen en el almacenamiento un original cuyo tamaño coincide, por lo que es probable que se puedan recuperar.\nPodemos usarlos para corregir la base de datos: pulsa el botón «Corregir» de abajo.\n\n${messageUnrecoverable}\n\nSi quieres volver a ejecutarlo, puedes hacerlo desde Hatch.\n",
"moduleMigration.fix0256.messageUnrecoverable": "**Archivos que no se pueden corregir en este dispositivo:**\n\n${filesNotRecoverable}\n\nEstos archivos tienen metadatos inconsistentes y no se pueden corregir en este dispositivo (por lo general no podemos determinar cuál es el correcto).\nPara restaurarlos, comprueba tus otros dispositivos (también con esta función) o restáuralos manualmente desde una copia de seguridad.\n",
"moduleMigration.fix0256.title": "Se han detectado archivos dañados",
"moduleMigration.insecureChunkExist.buttons.fetch": "Ya he reconstruido el remoto. Obtener desde el remoto",
"moduleMigration.insecureChunkExist.buttons.later": "Lo haré más tarde",
"moduleMigration.insecureChunkExist.buttons.rebuild": "Reconstruir todo",
"moduleMigration.insecureChunkExist.laterMessage": "¡Te recomendamos encarecidamente solucionarlo cuanto antes!",
"moduleMigration.insecureChunkExist.message": "Algunos chunks no se almacenan de forma segura y no están cifrados en las bases de datos.\n**Reconstruye la base de datos para solucionarlo**.\n\nSi tu base de datos remota no está configurada con SSL o usa credenciales poco seguras, **corres el riesgo de exponer datos sensibles**.\n\nNota: actualiza Self-hosted LiveSync a la v0.25.6 o superior en todos tus dispositivos y haz una copia de seguridad fiable de tu vault.\nNota 2: reconstruir todo y obtener los datos consume algo de tiempo y de tráfico; hazlo en horas de poco uso y con una conexión de red estable.\n",
"moduleMigration.insecureChunkExist.title": "¡Se han encontrado chunks no seguros!",
"moduleMigration.logBulkSendCorrupted": "El envío de fragmentos en bloque se ha habilitado, sin embargo, esta función se ha corrompido. Disculpe las molestias. Deshabilitado automáticamente.",
"moduleMigration.logFetchRemoteTweakFailed": "Error al obtener los valores de ajuste remoto",
"moduleMigration.logLocalDatabaseNotReady": "¡Algo salió mal! La base de datos local no está lista",
@@ -427,6 +513,14 @@
"moduleMigration.optionKeepPreviousBehaviour": "Mantener comportamiento anterior",
"moduleMigration.optionManualSetup": "Configurarlo todo manualmente",
"moduleMigration.optionNoAskAgain": "No, por favor pregúntame de nuevo",
"moduleMigration.optionNoSetupUri": "No, no tengo",
"moduleMigration.optionRemindNextLaunch": "Recordármelo en el próximo inicio",
"moduleMigration.optionSetupViaP2P": "Usar %{short_p2p_sync} para configurarlo",
"moduleMigration.optionSetupWizard": "Llévame al asistente de configuración",
"moduleMigration.optionYesFetchAgain": "Sí, obtener de nuevo",
"moduleMigration.titleCaseSensitivity": "Distinción de mayúsculas y minúsculas",
"moduleMigration.titleRecommendSetupUri": "Recomendación de usar un Setup URI",
"moduleMigration.titleWelcome": "Bienvenido a Self-hosted LiveSync",
"moduleObsidianMenu.replicate": "Replicar",
"More actions": "Más acciones",
"Mostly Complete: Decision Required": "Casi terminado: se requiere una decisión",
@@ -438,12 +532,15 @@
"New Remote": "Nuevo remoto",
"Newer (${diff})": "Más nuevo (${diff})",
"No checks have been performed yet.": "Todavía no se ha realizado ninguna comprobación.",
"No connected device information found. Cancelling Garbage Collection.": "No se ha encontrado información de dispositivos conectados. Se cancela la recolección de basura.",
"No Connection": "Sin conexión",
"No devices available. Waiting for other devices to connect...": "No hay dispositivos disponibles. Esperando a que se conecten otros dispositivos...",
"No Items.": "Sin elementos.",
"No limit configured": "Sin límite configurado",
"NO PREVIEW": "SIN VISTA PREVIA",
"No, please take me back": "No, volver atrás",
"Node ID": "ID de nodo",
"Node Information Missing": "Falta la información del nodo",
"Non-Synchronising files": "Archivos no sincronizados",
"Normal Files": "Archivos normales",
"Not all messages have been translated. And, please revert to \"Default\" when reporting errors.": "No todos los mensajes están traducidos. Por favor, vuelva a \"Predeterminado\" al reportar errores.",
@@ -459,6 +556,7 @@
"Number of changes to sync at a time. Defaults to 50. Minimum is 2.": "Número de cambios a sincronizar simultáneamente. Default 50, mínimo 2",
"Obfuscate Properties": "Ofuscar propiedades",
"Obfuscating properties (e.g., path of file, size, creation and modification dates) adds an additional layer of security by making it harder to identify the structure and names of your files and folders on the remote server. This helps protect your privacy and makes it more difficult for unauthorized users to infer information about your data.": "Ofuscar las propiedades (p. ej., la ruta del archivo, el tamaño y las fechas de creación y modificación) añade una capa extra de seguridad, ya que dificulta identificar la estructura y los nombres de tus archivos y carpetas en el servidor remoto. Esto ayuda a proteger tu privacidad y hace más difícil que usuarios no autorizados deduzcan información sobre tus datos.",
"Obsidian version": "Versión de Obsidian",
"obsidianLiveSyncSettingTab.btnApply": "Aplicar",
"obsidianLiveSyncSettingTab.btnCheck": "Verificar",
"obsidianLiveSyncSettingTab.btnCopy": "Copiar",
@@ -487,6 +585,7 @@
"obsidianLiveSyncSettingTab.errCorsNotAllowingCredentials": "CORS no permite credenciales",
"obsidianLiveSyncSettingTab.errCorsOrigins": "❗ cors.origins es incorrecto",
"obsidianLiveSyncSettingTab.errEnableCors": "❗ httpd.enable_cors es incorrecto",
"obsidianLiveSyncSettingTab.errEnableCorsChttpd": "❗ chttpd.enable_cors es incorrecto",
"obsidianLiveSyncSettingTab.errMaxDocumentSize": "❗ couchdb.max_document_size es bajo)",
"obsidianLiveSyncSettingTab.errMaxRequestSize": "❗ chttpd.max_http_request_size es bajo)",
"obsidianLiveSyncSettingTab.errMissingWwwAuth": "❗ httpd.WWW-Authenticate falta",
@@ -531,6 +630,7 @@
"obsidianLiveSyncSettingTab.msgDiscardConfirmation": "¿Realmente deseas descartar las configuraciones y bases de datos existentes?",
"obsidianLiveSyncSettingTab.msgDone": "--Hecho--",
"obsidianLiveSyncSettingTab.msgEnableCors": "Configurar httpd.enable_cors",
"obsidianLiveSyncSettingTab.msgEnableCorsChttpd": "Establecer chttpd.enable_cors",
"obsidianLiveSyncSettingTab.msgEnableEncryptionRecommendation": "Recomendamos habilitar el cifrado de extremo a extremo y la obfuscación de ruta. ¿Estás seguro de querer continuar sin cifrado?",
"obsidianLiveSyncSettingTab.msgFetchConfigFromRemote": "¿Quieres obtener la configuración del servidor remoto?",
"obsidianLiveSyncSettingTab.msgGenerateSetupURI": "¡Todo listo! ¿Quieres generar un URI de configuración para configurar otros dispositivos?",
@@ -571,6 +671,7 @@
"obsidianLiveSyncSettingTab.okCorsOriginMatched": "✔ Origen de CORS correcto",
"obsidianLiveSyncSettingTab.okCorsOrigins": "✔ cors.origins está correcto.",
"obsidianLiveSyncSettingTab.okEnableCors": "✔ httpd.enable_cors está correcto.",
"obsidianLiveSyncSettingTab.okEnableCorsChttpd": "✔ chttpd.enable_cors es correcto.",
"obsidianLiveSyncSettingTab.okMaxDocumentSize": "✔ couchdb.max_document_size está correcto.",
"obsidianLiveSyncSettingTab.okMaxRequestSize": "✔ chttpd.max_http_request_size está correcto.",
"obsidianLiveSyncSettingTab.okRequireValidUser": "✔ chttpd.require_valid_user está correcto.",
@@ -595,6 +696,8 @@
"obsidianLiveSyncSettingTab.panelPrivacyEncryption": "Privacidad y Cifrado",
"obsidianLiveSyncSettingTab.panelRemoteConfiguration": "Configuración remota",
"obsidianLiveSyncSettingTab.panelSetup": "Configuración",
"obsidianLiveSyncSettingTab.serverVersion": "Información del servidor: ${info}",
"obsidianLiveSyncSettingTab.titleActiveRemoteServer": "Servidor remoto activo",
"obsidianLiveSyncSettingTab.titleAppearance": "Apariencia",
"obsidianLiveSyncSettingTab.titleConflictResolution": "Resolución de conflictos",
"obsidianLiveSyncSettingTab.titleCongratulations": "¡Felicidades!",
@@ -647,6 +750,24 @@
"Overwrite Server Data with This Device's Files": "Sobrescribir los datos del servidor con los archivos de este dispositivo",
"P2P Configuration": "Configuración P2P",
"P2P Status": "Estado P2P",
"P2P.AskPassphraseForDecrypt": "El par remoto ha compartido la configuración. Introduce la frase de contraseña para descifrarla.",
"P2P.AskPassphraseForShare": "El par remoto ha solicitado la configuración de este dispositivo. Introduce la frase de contraseña para compartirla. Puedes ignorar la solicitud cancelando este diálogo.",
"P2P.DisabledButNeed": "%{title_p2p_sync} está desactivada. ¿Seguro que quieres activarla?",
"P2P.FailedToOpen": "No se pudo abrir la conexión P2P con el servidor de señalización.",
"P2P.NoAutoSyncPeers": "No se han encontrado pares de sincronización automática. Configúralos en el panel de %{long_p2p_sync}.",
"P2P.NoKnownPeers": "No se ha detectado ningún par; esperando a que se conecten otros...",
"P2P.Note.description": " Este replicador permite sincronizar el vault con otros dispositivos\nmediante una conexión punto a punto. Así se puede sincronizar con nuestros otros dispositivos sin usar un servicio en la nube.\nEl replicador se basa en Trystero. Usa además un servidor de señalización para establecer la conexión entre dispositivos. Ese servidor sirve para intercambiar la información de conexión y no conoce (ni debería almacenar) ninguno de nuestros datos.\n\nCualquiera puede alojar el servidor de señalización: es simplemente un relé Nostr. Por comodidad y para poder comprobar el comportamiento del replicador, vrtmrz aloja una instancia. Puedes usar ese servidor experimental o cualquier otro.\n\nPor cierto, aunque el servidor de señalización no almacene nuestros datos, sí puede ver la información de conexión de algunos de nuestros dispositivos. Tenlo en cuenta y ten precaución al usar un servidor de terceros.",
"P2P.Note.important_note": "Replicador punto a punto.",
"P2P.Note.important_note_sub": "Esta función sigue siendo muy experimental. Asegúrate de tener una copia de seguridad de tus datos antes de usarla. Y nos alegraría mucho que quisieras contribuir a su desarrollo.",
"P2P.Note.Summary": "¿Qué es esta función? (incluye notas importantes; léelas al menos una vez)",
"P2P.NotEnabled": "%{title_p2p_sync} no está activada. No se puede abrir una conexión nueva.",
"P2P.P2PReplication": "Replicación %{P2P}",
"P2P.PaneTitle": "%{long_p2p_sync}",
"P2P.ReplicatorInstanceMissing": "No se encuentra el replicador de sincronización P2P; puede que no esté configurado o activado.",
"P2P.SeemsOffline": "El par ${name} parece estar desconectado; se omite.",
"P2P.SyncAlreadyRunning": "La sincronización P2P ya está en marcha.",
"P2P.SyncCompleted": "Sincronización P2P completada.",
"P2P.SyncStartedWith": "Se ha iniciado la sincronización P2P con ${name}.",
"paneMaintenance.markDeviceResolvedAfterBackup": "Marcar el dispositivo como resuelto después de hacer una copia de seguridad",
"paneMaintenance.remoteLockedAndDeviceNotAccepted": "La base de datos remota está bloqueada y este dispositivo aún no ha sido aceptado.",
"paneMaintenance.remoteLockedResolvedDevice": "La base de datos remota está bloqueada, pero este dispositivo ya fue aceptado.",
@@ -675,14 +796,18 @@
"Periodic Sync interval": "Intervalo de sincronización periódica",
"PERMANENT": "PERMANENTE",
"Pick a file to resolve conflict": "Elegir un archivo para resolver el conflicto",
"Pick a file to show history": "Elige un archivo para ver su historial",
"Please be aware that the End-to-End Encryption passphrase is not validated until the synchronisation process actually commences. This is a security measure designed to protect your data.": "Ten en cuenta que la frase de contraseña del cifrado de extremo a extremo no se valida hasta que comienza realmente la sincronización. Es una medida de seguridad para proteger tus datos.",
"Please configure your end-to-end encryption settings.": "Configura los ajustes de cifrado de extremo a extremo.",
"Please disable 'Read chunks online' in settings to use Garbage Collection.": "Desactiva «Leer chunks en línea» en los ajustes para poder usar la recolección de basura.",
"Please enable 'Compute revisions for chunks' in settings to use Garbage Collection.": "Activa «Calcular revisiones para los chunks» en los ajustes para poder usar la recolección de basura.",
"Please enter the CouchDB server information below.": "Introduce a continuación los datos del servidor CouchDB.",
"Please enter the details required to connect to your S3/MinIO/R2 compatible object storage service.": "Introduce los datos necesarios para conectarte a tu servicio de almacenamiento de objetos compatible con S3/MinIO/R2.",
"Please enter the Peer-to-Peer Synchronisation information below.": "Introduce a continuación los datos de la sincronización punto a punto.",
"Please enter the Setup URI that was generated during server installation or on another device, along with the vault passphrase.": "Introduce el Setup URI generado durante la instalación del servidor o en otro dispositivo, junto con la frase de contraseña del vault.",
"Please follow the steps below to import settings from your existing device.": "Sigue los pasos siguientes para importar los ajustes desde tu dispositivo actual.",
"PLEASE NOTE": "TEN EN CUENTA",
"Please select 'Cancel' explicitly to cancel this operation.": "Selecciona «Cancelar» de forma explícita para cancelar esta operación.",
"Please select a method to import the settings from another device.": "Seleccione un método para importar la configuración desde otro dispositivo。",
"Please select an active P2P remote configuration to change P2P sync targets.": "Selecciona una configuración remota P2P activa para cambiar los destinos de sincronización P2P.",
"Please select an option to proceed": "Seleccione una opción para continuar",
@@ -693,20 +818,38 @@
"Please set device name to identify this device. This name should be unique among your devices. While not configured, we cannot enable this feature.": "Define un nombre para identificar este dispositivo. Debe ser único entre tus dispositivos. Mientras no esté configurado, no podremos habilitar esta función.",
"Please set this device name": "Define el nombre de este dispositivo",
"Please understand that this is intended behaviour.": "Comprende que este es el comportamiento previsto.",
"Plug-in version": "Versión del complemento",
"Plugins": "Complementos",
"Prepare the 'report' to create an issue": "Preparar el «informe» para abrir una incidencia",
"Presets": "Preconfiguraciones",
"Prevent fetching configuration from server": "Impedir la obtención de la configuración desde el servidor",
"Proceed": "Continuar",
"Proceed Garbage Collection": "Continuar con la recolección de basura",
"Proceed to the next step.": "Continuar al paso siguiente.",
"Proceed with Setup URI": "Continuar con el URI de configuración",
"Proceeding with Garbage Collection, ignoring missing nodes.": "Se continúa con la recolección de basura, ignorando los nodos ausentes.",
"Proceeding with Garbage Collection.": "Se continúa con la recolección de basura.",
"Process small files in the foreground": "Procesar archivos pequeños en primer plano",
"Progress": "Progreso",
"Property Encryption": "Cifrado de propiedades",
"PureJS fallback (Fast, W/O WebAssembly)": "Alternativa PureJS (rápida, sin WebAssembly)",
"Purge all download/upload cache.": "Purga toda la caché de descarga y carga.",
"Purge all journal counter": "Purgar todos los contadores del diario",
"Rebuild local and remote database with local files.": "Reconstruye la base de datos local y remota usando los archivos locales.",
"Rebuilding Operations (Remote Only)": "Operaciones de reconstrucción (solo remoto)",
"Recovery and Repair": "Recuperación y reparación",
"Recreate all": "Recrear todo",
"Recreate missing chunks for all files": "Recrear fragmentos faltantes para todos los archivos",
"RedFlag.Fetch.Method.Desc": "¿Cómo quieres obtener los datos?\n- %{RedFlag.Fetch.Method.FetchSafer}.\n **Poco tráfico**, **mucha CPU**, **riesgo bajo**\n Recomendado si...\n - Los archivos podrían ser inconsistentes\n - No hay demasiados archivos\n- %{RedFlag.Fetch.Method.FetchSmoother}.\n **Poco tráfico**, **CPU moderada**, **riesgo bajo o moderado**\n Recomendado si...\n - Los archivos son probablemente consistentes\n - Tienes muchos archivos\n- %{RedFlag.Fetch.Method.FetchTraditional}.\n **Mucho tráfico**, **poca CPU**, **riesgo bajo o moderado**\n\n>[!INFO]- Detalles\n> ## %{RedFlag.Fetch.Method.FetchSafer}.\n> **Poco tráfico**, **mucha CPU**, **riesgo bajo**\n> Esta opción crea primero una base de datos local a partir de los archivos locales existentes antes de obtener los datos del remoto.\n> Si un archivo existe tanto en local como en remoto, solo se transferirán las diferencias.\n> Sin embargo, los archivos presentes en ambos sitios se tratarán inicialmente como archivos en conflicto. Se resolverán automáticamente si en realidad no lo están, pero el proceso puede tardar.\n> En general es el método más seguro y el que menos riesgo de pérdida de datos conlleva.\n> ## %{RedFlag.Fetch.Method.FetchSmoother}.\n> **Poco tráfico**, **CPU moderada**, **riesgo bajo o moderado** (según la operación)\n> Esta opción crea primero los chunks de los archivos locales para la base de datos y después obtiene los datos. Así solo se transfieren los chunks que faltan en local. Aun así, todos los metadatos se toman del remoto.\n> Al iniciar, los archivos locales se comparan con esos metadatos. El contenido considerado más reciente sobrescribirá al más antiguo (según la fecha de modificación) y el resultado se sincroniza de vuelta a la base de datos remota.\n> Es seguro si los archivos locales son realmente los de fecha más reciente, pero puede dar problemas si un archivo tiene una fecha más nueva y un contenido más antiguo (como el `welcome.md` inicial).\n> Usa menos CPU y es más rápido que «%{RedFlag.Fetch.Method.FetchSafer}», pero puede provocar pérdida de datos si no se usa con cuidado.\n> ## %{RedFlag.Fetch.Method.FetchTraditional}.\n> **Mucho tráfico**, **poca CPU**, **riesgo bajo o moderado** (según la operación)\n> Se obtiene todo del remoto.\n> Similar a %{RedFlag.Fetch.Method.FetchSmoother}, pero todos los chunks se descargan del remoto.\n> Es la forma más tradicional de obtener los datos y normalmente la que más tráfico y tiempo consume. Conlleva un riesgo de sobrescribir archivos remotos parecido al de «%{RedFlag.Fetch.Method.FetchSmoother}».\n> Aun así, suele considerarse el método más estable, por ser el más antiguo y directo.",
"RedFlag.Fetch.Method.FetchSafer": "Crear una base de datos local antes de obtener los datos",
"RedFlag.Fetch.Method.FetchSmoother": "Crear los chunks de los archivos locales antes de obtener los datos",
"RedFlag.Fetch.Method.FetchTraditional": "Obtener todo del remoto",
"RedFlag.Fetch.Method.Title": "¿Cómo quieres obtener los datos?",
"RedFlag.FetchRemoteConfig.Buttons.Cancel": "No, usar los ajustes locales",
"RedFlag.FetchRemoteConfig.Buttons.Fetch": "Sí, obtener y aplicar los ajustes remotos",
"RedFlag.FetchRemoteConfig.Message": "¿Quieres obtener y aplicar en este dispositivo los ajustes de preferencias guardados en el remoto?",
"RedFlag.FetchRemoteConfig.Title": "Obtener la configuración remota",
"Reduces storage space by discarding all non-latest revisions. This requires the same amount of free space on the remote server and the local client.": "Reduce el espacio de almacenamiento descartando todas las revisiones que no sean la última. Requiere la misma cantidad de espacio libre en el servidor remoto y en el cliente local.",
"Reducing the frequency with which on-disk changes are reflected into the DB": "Reducir frecuencia de actualizaciones de disco a BD",
"Refresh": "Actualizar",
"Region": "Región",
@@ -724,8 +867,23 @@
"Replicate now": "Replicar ahora",
"Replicating": "Replicando",
"Replicating...": "Replicando...",
"Replicator.Dialogue.Locked.Action.Dismiss": "Cancelar para volver a confirmarlo",
"Replicator.Dialogue.Locked.Action.Fetch": "Restablecer la sincronización en este dispositivo",
"Replicator.Dialogue.Locked.Action.Unlock": "Desbloquear la base de datos remota",
"Replicator.Dialogue.Locked.Message": "La base de datos remota está bloqueada porque se ha reconstruido en uno de los dispositivos.\nPor eso se pide a este dispositivo que no se conecte, para evitar corromper la base de datos.\n\nHay tres opciones posibles:\n\n- %{Replicator.Dialogue.Locked.Action.Fetch}\n La más recomendable y fiable. Descarta la base de datos local y vuelve a tomar toda la información de sincronización del remoto. En la mayoría de los casos se puede hacer sin riesgo, aunque lleva algo de tiempo y conviene hacerlo con una red estable.\n- %{Replicator.Dialogue.Locked.Action.Unlock}\n Solo se puede usar si ya estamos sincronizados de forma fiable por otros métodos de replicación. Esto no significa simplemente tener los mismos archivos. Si no estás seguro, evítala.\n- %{Replicator.Dialogue.Locked.Action.Dismiss}\n Cancela la operación. Se volverá a preguntar en la próxima solicitud.\n",
"Replicator.Dialogue.Locked.Message.Fetch": "Se ha programado la obtención completa. El complemento se reiniciará para llevarla a cabo.",
"Replicator.Dialogue.Locked.Message.Unlocked": "La base de datos remota se ha desbloqueado. Vuelve a intentar la operación.",
"Replicator.Dialogue.Locked.Title": "Bloqueada",
"Replicator.Message.Cleaned": "Se está limpiando la base de datos; la replicación se ha cancelado",
"Replicator.Message.InitialiseFatalError": "No hay ningún replicador disponible; se trata de un error grave.",
"Replicator.Message.Pending": "Hay eventos de archivo pendientes. La replicación se ha cancelado.",
"Replicator.Message.SomeModuleFailed": "La replicación se ha cancelado por el fallo de algún módulo",
"Replicator.Message.VersionUpFlash": "Se ha detectado una actualización. Abre el diálogo de ajustes y consulta el registro de cambios. La replicación se ha cancelado.",
"Requires restart of Obsidian": "Requiere reiniciar Obsidian",
"Requires restart of Obsidian.": "Requiere reiniciar Obsidian",
"Rerun Onboarding Wizard": "Volver a ejecutar el asistente de configuración inicial",
"Rerun the onboarding wizard to set up Self-hosted LiveSync again.": "Vuelve a ejecutar el asistente de configuración inicial para configurar Self-hosted LiveSync de nuevo.",
"Rerun Wizard": "Volver a ejecutar el asistente",
"Resend": "Reenviar",
"Resend all chunks to the remote.": "Reenvía todos los chunks al remoto.",
"Reset": "Restablecer",
@@ -734,10 +892,12 @@
"Reset and Resume Synchronisation": "Restablecer y reanudar la sincronización",
"Reset journal received history": "Restablecer historial de recepción del diario",
"Reset journal sent history": "Restablecer historial de envío del diario",
"Reset notification threshold and check the remote database usage": "Restablecer el umbral de aviso y comprobar el uso de la base de datos remota",
"Reset received": "Restablecer recepción",
"Reset sent history": "Restablecer historial de envío",
"Reset Synchronisation information": "Restablecer información de sincronización",
"Reset Synchronisation on This Device": "Restablecer sincronización en este dispositivo",
"Reset the remote storage size threshold and check the remote storage size again.": "Restablece el umbral de tamaño del almacenamiento remoto y vuelve a comprobar su tamaño.",
"Resolve All": "Resolver todo",
"Resolve all conflicted files": "Resolver todos los archivos en conflicto",
"Resolve All conflicted files by the newer one": "Resolver todos los archivos en conflicto con la versión más reciente",
@@ -745,12 +905,14 @@
"Restart and Fetch Data": "Reiniciar y obtener los datos",
"Restart and Initialise Server": "Reiniciar e inicializar el servidor",
"Restart Now": "Reiniciar ahora",
"Restarting Obsidian is strongly recommended. Until restart, some changes may not take effect, and display may be inconsistent. Are you sure to restart now?": "Se recomienda encarecidamente reiniciar Obsidian. Hasta que lo hagas, puede que algunos cambios no surtan efecto y que la interfaz se muestre de forma inconsistente. ¿Seguro que quieres reiniciar ahora?",
"Restore or reconstruct local database from remote.": "Restaura o reconstruye la base de datos local desde el remoto.",
"Rev": "Rev",
"Revert changes": "Revertir cambios",
"Revoke": "Revocar",
"Room ID": "ID de sala",
"Room ID suffix:": "Sufijo del ID de sala:",
"Run Doctor": "Ejecutar el Doctor",
"S3/MinIO/R2 Configuration": "Configuración de S3/MinIO/R2",
"S3/MinIO/R2 Object Storage": "Almacenamiento de objetos S3/MinIO/R2",
"Same": "Igual",
@@ -765,11 +927,13 @@
"Scan customization before replicating.": "Escanear personalización antes de replicar",
"Scan customization every 1 minute.": "Escanear personalización cada 1 minuto",
"Scan customization periodically": "Escanear personalización periódicamente",
"Scan for Broken files": "Buscar archivos dañados",
"Scan for hidden files before replication": "Escanear archivos ocultos antes de replicar",
"Scan hidden files periodically": "Escanear archivos ocultos periódicamente",
"Scan QR Code": "Escanear código QR",
"Scan the QR code displayed on an active device using this device's camera.": "Escanee con la cámara de este dispositivo el código QR mostrado en un dispositivo activo。",
"Schedule and Restart": "Programar y reiniciar",
"Scram Switches": "Interruptores de emergencia",
"Scram!": "Medidas de emergencia",
"Seconds, 0 to disable": "Segundos, 0 para desactivar",
"Seconds. Saving to the local database will be delayed until this value after we stop typing or saving.": "Segundos. Guardado en BD local se retrasará hasta este valor tras dejar de escribir/guardar",
@@ -790,21 +954,44 @@
"SESSION": "SESIÓN",
"Setting.GenerateKeyPair.Desc": "Hemos generado un par de claves.\n\nNota: Este par de claves no volverá a mostrarse. Guárdalo en un lugar seguro. Si lo pierdes, tendrás que generar uno nuevo.\nNota 2: La clave pública está en formato spki y la clave privada en formato pkcs8. Para mayor comodidad, los saltos de línea de la clave pública se convierten en `\\n`.\nNota 3: La clave pública debe configurarse en la base de datos remota y la clave privada en los dispositivos locales.\n\n>[!SOLO PARA TUS OJOS]-\n> <div class=\"sls-keypair\">\n>\n> ### Clave pública\n> ```\n${public_key}\n> ```\n>\n> ### Clave privada\n> ```\n${private_key}\n> ```\n>\n> </div>\n\n>[!Ambas para copiar]-\n>\n> <div class=\"sls-keypair\">\n>\n> ```\n${public_key}\n${private_key}\n> ```\n>\n> </div>",
"Setting.GenerateKeyPair.Title": "¡Se ha generado un nuevo par de claves!",
"Setting.TroubleShooting": "Resolución de problemas",
"Setting.TroubleShooting.Doctor": "Doctor de ajustes",
"Setting.TroubleShooting.Doctor.Desc": "Detecta ajustes no óptimos. (Igual que durante la migración)",
"Setting.TroubleShooting.ScanBrokenFiles": "Buscar archivos dañados",
"Setting.TroubleShooting.ScanBrokenFiles.Desc": "Busca archivos que no se hayan guardado correctamente en la base de datos.",
"SettingTab.Message.AskRebuild": "Tus cambios requieren obtener los datos de la base de datos remota. ¿Quieres continuar?",
"Setup Complete: Preparing to Fetch Synchronisation Data": "Configuración completada: preparando la obtención de los datos de sincronización",
"Setup Complete: Preparing to Initialise Server": "Configuración completada: preparando la inicialización del servidor",
"Setup URI dialog cancelled.": "Se ha cancelado el diálogo del Setup URI.",
"Setup-URI": "Setup-URI",
"Setup.> [!INFO]- The connected devices have been detected as follows:\n${devices}": "> [!INFO]- Se detectaron los siguientes dispositivos conectados:\n${devices}",
"Setup.All devices have the same progress value (${progress}). Your devices seem to be synchronised. And be able to proceed with Garbage Collection.": "Todos los dispositivos tienen el mismo valor de progreso (${progress}). Parece que tus dispositivos están sincronizados y se puede continuar con la recolección de basura.",
"Setup.Apply.Buttons.ApplyAndFetch": "Aplicar y obtener",
"Setup.Apply.Buttons.ApplyAndMerge": "Aplicar y combinar",
"Setup.Apply.Buttons.ApplyAndRebuild": "Aplicar y reconstruir",
"Setup.Apply.Buttons.Cancel": "Descartar y cancelar",
"Setup.Apply.Buttons.OnlyApply": "Solo aplicar",
"Setup.Apply.Message": "La nueva configuración está lista. Vamos a aplicarla.\nHay varias formas de hacerlo:\n\n- Aplicar y obtener\n Configura este dispositivo como cliente nuevo. Tras aplicarla, sincroniza desde el servidor remoto.\n- Aplicar y combinar\n Para un dispositivo que ya tiene los archivos. Procesa los archivos locales y transfiere las diferencias. Pueden surgir conflictos.\n- Aplicar y reconstruir\n Reconstruye el remoto a partir de los archivos locales. Se hace normalmente si el servidor se ha corrompido o si se quiere empezar de cero.\n Los demás dispositivos quedarán bloqueados y tendrán que volver a obtener los datos.\n- Solo aplicar\n Solo aplica la configuración. Pueden surgir conflictos si hace falta reconstruir.",
"Setup.Apply.Title": "Aplicar la nueva configuración de ${method}",
"Setup.Apply.WarningRebuildRecommended": "NOTA: tras ajustar la configuración se ha determinado que hace falta reconstruir; no se recomienda «solo importar».",
"Setup.Cancel Garbage Collection": "Cancelar la recolección de basura",
"Setup.Compaction in progress on remote database...": "La compactación está en curso en la base de datos remota...",
"Setup.Compaction on remote database completed successfully.": "La compactación en la base de datos remota se completó correctamente.",
"Setup.Compaction on remote database failed.": "La compactación en la base de datos remota falló.",
"Setup.Compaction on remote database timed out.": "La compactación en la base de datos remota agotó el tiempo de espera.",
"Setup.Device": "Dispositivo",
"Setup.Doctor.Buttons.No": "No, usar los ajustes del URI tal cual",
"Setup.Doctor.Buttons.Yes": "Sí, consultar al doctor",
"Setup.Doctor.Message": "Self-hosted LiveSync tiene ya una historia larga y algunos ajustes recomendados han cambiado.\n\nLa configuración inicial es un momento muy bueno para revisarlos.\n\n¿Quieres ejecutar el Doctor para comprobar si los ajustes importados son los óptimos respecto al estado actual?",
"Setup.Doctor.Title": "¿Quieres consultar al doctor?",
"Setup.Failed to connect to remote for compaction.": "No se pudo conectar a la base de datos remota para la compactación.",
"Setup.Failed to connect to remote for compaction. ${reason}": "No se pudo conectar a la base de datos remota para la compactación. ${reason}",
"Setup.Failed to start one-shot replication before Garbage Collection. Garbage Collection Cancelled.": "No se pudo iniciar la replicación de una sola vez antes de la recolección de basura. La recolección de basura se canceló.",
"Setup.Failed to start replication after Garbage Collection.": "No se pudo iniciar la replicación después de la recolección de basura.",
"Setup.FetchRemoteConf.Buttons.Fetch": "Sí, obtener la configuración",
"Setup.FetchRemoteConf.Buttons.Skip": "No, usar los ajustes del URI",
"Setup.FetchRemoteConf.Message": "Si ya hemos sincronizado alguna vez con otro dispositivo, la base de datos remota guarda los valores de configuración adecuados para los dispositivos sincronizados. El complemento querría recuperarlos para lograr una configuración más sólida.\n\nAntes hay que asegurar una cosa: ¿estamos en una situación en la que se puede acceder a la red de forma segura y recuperar los ajustes?\n\nNota: normalmente puedes hacerlo sin riesgo si tu base de datos remota se sirve con un certificado SSL y tu red no está comprometida.",
"Setup.FetchRemoteConf.Title": "¿Obtener la configuración de la base de datos remota?",
"Setup.Garbage Collection cancelled by user.": "El usuario canceló la recolección de basura.",
"Setup.Garbage Collection completed. Deleted chunks: ${deletedChunks} / ${totalChunks}. Time taken: ${seconds} seconds.": "Recolección de basura completada. Chunks eliminados: ${deletedChunks} / ${totalChunks}. Tiempo empleado: ${seconds} segundos.",
"Setup.Garbage Collection Confirmation": "Confirmación de recolección de basura",
@@ -828,6 +1015,7 @@
"Setup.Proceeding with Garbage Collection, ignoring missing nodes.": "Continuando con la recolección de basura e ignorando los nodos faltantes.",
"Setup.Proceeding with Garbage Collection.": "Continuando con la recolección de basura.",
"Setup.Progress": "Progreso",
"Setup.QRCode": "Hemos generado un código QR para transferir los ajustes. Escanéalo con tu móvil u otro dispositivo.\nNota: el código QR no está cifrado, así que ten cuidado al abrirlo.\n\n>[!SOLO PARA TUS OJOS]-\n> <div class=\"sls-qr\">${qr_image}</div>",
"Setup.RemoteE2EE.AdvancedTitle": "Avanzado",
"Setup.RemoteE2EE.AlgorithmWarning": "Cambiar el algoritmo de cifrado impedirá el acceso a cualquier dato cifrado anteriormente con otro algoritmo. Asegúrate de que todos tus dispositivos estén configurados para usar el mismo algoritmo y así mantener el acceso a tus datos.",
"Setup.RemoteE2EE.ButtonCancel": "Cancelar",
@@ -854,6 +1042,8 @@
"Setup.ScanQRCode.Step4": "En este dispositivo, cambia a la cámara o usa un escáner QR para escanear el código mostrado.",
"Setup.ScanQRCode.Title": "Escanear código QR",
"Setup.Setup URI dialog cancelled.": "Se canceló el diálogo de Setup URI.",
"Setup.ShowQRCode": "Mostrar el código QR",
"Setup.ShowQRCode.Desc": "Muestra un código QR para transferir los ajustes.",
"Setup.Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": "Algunos dispositivos tienen valores de progreso diferentes (máx.: ${maxProgress}, mín.: ${minProgress}).\nEsto puede indicar que algunos dispositivos no han completado la sincronización, lo que podría causar conflictos. Se recomienda encarecidamente confirmar que todos los dispositivos estén sincronizados antes de continuar.",
"Setup.The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": "Los siguientes nodos aceptados no tienen información del nodo:\n- ${missingNodes}\n\nEsto indica que no se han conectado desde hace algún tiempo o que se han quedado en una versión anterior.\nSi es posible, es preferible actualizar todos los dispositivos. Si tienes dispositivos que ya no se usan, puedes borrar todos los nodos aceptados bloqueando el remoto una vez.",
"Setup.titleCaseSensitivity": "Sensibilidad a mayúsculas",
@@ -876,6 +1066,8 @@
"Should we prompt you about conflicting files when a file is opened?": "¿Notificar sobre conflictos al abrir archivo?",
"Should we prompt you for every single merge, even if we can safely merge automatcially?": "¿Preguntar en cada fusión aunque sea automática?",
"Show full banner": "Mostrar banner completo",
"Show history": "Mostrar el historial",
"Show icon only": "Mostrar solo el icono",
"Show only notifications": "Mostrar solo notificaciones",
"Show status as icons only": "Mostrar estado solo con íconos",
"Show status icon instead of file warnings banner": "Mostrar icono de estado en lugar del banner de advertencia de archivos",
@@ -886,6 +1078,7 @@
"Signalling Status": "Estado de la señalización",
"Skip and close": "Omitir y cerrar",
"Snippets": "Fragmentos",
"Some devices have differing progress values (max: ${maxProgress}, min: ${minProgress}).\nThis may indicate that some devices have not completed synchronisation, which could lead to conflicts. Strongly recommend confirming that all devices are synchronised before proceeding.": "Algunos dispositivos tienen valores de progreso distintos (máx.: ${maxProgress}, mín.: ${minProgress}).\nEsto puede indicar que algunos no han terminado de sincronizar, lo que podría provocar conflictos. Se recomienda encarecidamente confirmar que todos los dispositivos están sincronizados antes de continuar.",
"Start Broadcasting": "Iniciar difusión",
"Start change-broadcasting on Connect": "Iniciar la difusión de cambios al conectar",
"Start Sync & Close": "Iniciar sincronización y cerrar",
@@ -896,6 +1089,7 @@
"Stop Broadcasting": "Detener difusión",
"Stop reflecting database changes to storage files.": "Dejar de reflejar cambios de BD en archivos",
"Stop watching for file changes.": "Dejar de monitorear cambios en archivos",
"Storage -> Database": "Almacenamiento -> Base de datos",
"Strongly Recommended": "Muy recomendado",
"Suppress notification of hidden files change": "Suprimir notificaciones de cambios en archivos ocultos",
"Suspend database reflecting": "Suspender reflejo de base de datos",
@@ -921,13 +1115,16 @@
"The connection to the server has been configured successfully. As the next step,": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente,",
"The delay for consecutive on-demand fetches": "Retraso entre obtenciones consecutivas",
"The files in this Vault are almost identical to the server's.": "Los archivos de este Vault son casi idénticos a los del servidor.",
"The following accepted nodes are missing its node information:\n- ${missingNodes}\n\nThis indicates that they have not been connected for some time or have been left on an older version.\nIt is preferable to update all devices if possible. If you have any devices that are no longer in use, you can clear all accepted nodes by locking the remote once.": "A los siguientes nodos aceptados les falta su información de nodo:\n- ${missingNodes}\n\nEsto indica que llevan tiempo sin conectarse o que se han quedado en una versión antigua.\nSi es posible, conviene actualizar todos los dispositivos. Si tienes dispositivos que ya no usas, puedes borrar todos los nodos aceptados bloqueando el remoto una vez.",
"The Group ID and passphrase are used to identify your group of devices. Make sure to use the same Group ID and passphrase on all devices you want to synchronise.": "El ID de grupo y la frase de contraseña identifican tu grupo de dispositivos. Usa el mismo ID de grupo y la misma frase de contraseña en todos los dispositivos que quieras sincronizar.",
"The Hash algorithm for chunk IDs": "Algoritmo hash para IDs de chunks",
"The IndexedDB adapter often offers superior performance in certain scenarios, but it has been found to cause memory leaks when used with LiveSync mode. When using LiveSync mode, please use IDB adapter instead.": "El adaptador IndexedDB suele ofrecer mejor rendimiento en ciertos casos, pero se ha comprobado que provoca fugas de memoria con el modo LiveSync. Si usas el modo LiveSync, utiliza en su lugar el adaptador IDB.",
"the latest synchronisation data will be downloaded from the server to this device.": "se descargarán a este dispositivo los datos de sincronización más recientes del servidor.",
"the local database, that is to say the synchronisation information, must be reconstituted.": "hay que reconstruir la base de datos local, es decir, la información de sincronización.",
"The maximum duration for which chunks can be incubated within the document. Chunks exceeding this period will graduate to independent chunks.": "Duración máxima para incubar chunks. Excedentes se independizan",
"The maximum number of chunks that can be incubated within the document. Chunks exceeding this number will immediately graduate to independent chunks.": "Número máximo de chunks que pueden incubarse en el documento. Excedentes se independizan",
"The maximum total size of chunks that can be incubated within the document. Chunks exceeding this size will immediately graduate to independent chunks.": "Tamaño total máximo de chunks incubados. Excedentes se independizan",
"The minimum interval for automatic synchronisation on event.": "Intervalo mínimo para la sincronización automática al producirse un evento.",
"The remote is already set up, and the configuration is compatible (or got compatible by this operation).": "El remoto ya está configurado y la configuración es compatible (o pasa a serlo con esta operación).",
"The Setup-URI does not appear to be valid. Please check that you have copied it correctly.": "El Setup-URI no parece válido. Comprueba que lo hayas copiado correctamente.",
"The Setup-URI is valid and ready to use.": "El Setup-URI es válido y está listo para usarse.",
@@ -957,6 +1154,297 @@
"TURN server settings are only necessary if you are behind a strict NAT or firewall that prevents direct P2P connections. In most cases, you can leave these fields blank.": "Los ajustes del servidor TURN solo son necesarios si estás detrás de un NAT estricto o de un cortafuegos que impide las conexiones P2P directas. En la mayoría de los casos puedes dejar estos campos vacíos.",
"TURN Server URLs (comma-separated)": "URL de servidores TURN (separadas por comas)",
"TURN Username": "Usuario de TURN",
"TweakMismatchResolve.Action.DisableAutoAcceptCompatible": "Desactivar la aceptación automática",
"TweakMismatchResolve.Action.Dismiss": "Descartar",
"TweakMismatchResolve.Action.EnableAutoAcceptCompatible": "Activar la aceptación automática",
"TweakMismatchResolve.Action.UseConfigured": "Usar los ajustes configurados",
"TweakMismatchResolve.Action.UseMine": "Actualizar los ajustes de la base de datos remota",
"TweakMismatchResolve.Action.UseMineAcceptIncompatible": "Actualizar los ajustes de la base de datos remota, pero dejarlo como está",
"TweakMismatchResolve.Action.UseMineWithRebuild": "Actualizar los ajustes de la base de datos remota y reconstruir de nuevo",
"TweakMismatchResolve.Action.UseRemote": "Aplicar los ajustes a este dispositivo",
"TweakMismatchResolve.Action.UseRemoteAcceptIncompatible": "Aplicar los ajustes a este dispositivo e ignorar la incompatibilidad",
"TweakMismatchResolve.Action.UseRemoteWithRebuild": "Aplicar los ajustes a este dispositivo y volver a obtener los datos",
"TweakMismatchResolve.Message.AutoAcceptCompatibleUndefined": "\nParece que los ajustes son distintos en cada dispositivo. Ahora se pueden aplicar automáticamente los cambios compatibles a estas configuraciones.\n¿Quieres activar la aceptación automática (`auto-accept`)?",
"TweakMismatchResolve.Message.Main": "\nLos ajustes de la base de datos remota son los siguientes. Los han configurado otros dispositivos que se han sincronizado con este al menos una vez.\n\nSi quieres usar estos ajustes, selecciona %{TweakMismatchResolve.Action.UseConfigured}.\nSi prefieres conservar los de este dispositivo, selecciona %{TweakMismatchResolve.Action.Dismiss}.\n\n${table}\n\n>[!TIP]\n> Si quieres sincronizar todos los ajustes, usa «Sync settings via markdown» después de aplicar la configuración mínima con esta función.\n\n${additionalMessage}",
"TweakMismatchResolve.Message.MainTweakResolving": "Tu configuración no coincide con la del servidor remoto.\n\nLa siguiente configuración debería coincidir:\n\n${table}\n\nIndícanos qué decides.\n\n${additionalMessage}",
"TweakMismatchResolve.Message.mineUpdated": "Se ha ajustado la configuración del dispositivo.",
"TweakMismatchResolve.Message.remoteUpdated": "Se ha actualizado la configuración almacenada en el remoto.",
"TweakMismatchResolve.Message.UseRemote.WarningRebuildRecommended": "\n>[!NOTICE]\n> Algunos cambios son compatibles, pero pueden consumir almacenamiento y transferencia de más. Se recomienda reconstruir. De momento puede que no se reconstruya, pero podría hacerse en un mantenimiento futuro.\n> ***Asegúrate de tener tiempo y una red estable antes de aplicarlo.***",
"TweakMismatchResolve.Message.UseRemote.WarningRebuildRequired": "\n>[!WARNING]\n> Algunas configuraciones remotas no son compatibles con la base de datos local de este dispositivo. Habrá que reconstruirla.\n> ***Asegúrate de tener tiempo y una red estable antes de aplicarlo.***",
"TweakMismatchResolve.Message.WarningIncompatibleRebuildRecommended": "\n>[!NOTICE]\n> Hemos detectado que algunos valores difieren de forma que hacen incompatible la base de datos local con la remota.\n> Algunos cambios son compatibles, pero pueden consumir almacenamiento y transferencia de más. Se recomienda reconstruir. De momento puede que no se reconstruya, pero podría hacerse en un mantenimiento futuro.\n> Si decides reconstruir, tardará unos minutos o más. **Asegúrate de que es seguro hacerlo ahora.**",
"TweakMismatchResolve.Message.WarningIncompatibleRebuildRequired": "\n>[!WARNING]\n> Hemos detectado que algunos valores difieren de forma que hacen incompatible la base de datos local con la remota.\n> Hay que reconstruir la local o la remota. Ambas cosas tardan unos minutos o más. **Asegúrate de que es seguro hacerlo ahora.**",
"TweakMismatchResolve.Table": "| Nombre del valor | Este dispositivo | En el remoto |\n|: --- |: ---- :|: ---- :|\n${rows}\n\n",
"TweakMismatchResolve.Table.Row": "| ${name} | ${self} | ${remote} |",
"TweakMismatchResolve.Title": "Se ha detectado una discrepancia de configuración",
"TweakMismatchResolve.Title.AutoAcceptCompatible": "Aceptación automática disponible",
"TweakMismatchResolve.Title.TweakResolving": "Se ha detectado una discrepancia de configuración",
"TweakMismatchResolve.Title.UseRemoteConfig": "Usar la configuración remota",
"Ui.Common.Signal.Caution": "PRECAUCIÓN",
"Ui.Common.Signal.Danger": "PELIGRO",
"Ui.Common.Signal.Notice": "AVISO",
"Ui.Common.Signal.Warning": "ADVERTENCIA",
"Ui.Settings.Advanced.LocalDatabaseTweak": "Ajuste fino de la base de datos local",
"Ui.Settings.Advanced.MemoryCache": "Caché en memoria",
"Ui.Settings.Advanced.TransferTweak": "Ajuste fino de la transferencia",
"Ui.Settings.Common.Analyse": "Analizar",
"Ui.Settings.Common.Back": "Volver",
"Ui.Settings.Common.Check": "Comprobar",
"Ui.Settings.Common.Configure": "Configurar",
"Ui.Settings.Common.Continue": "Continuar",
"Ui.Settings.Common.Delete": "Eliminar",
"Ui.Settings.Common.Fetch": "Obtener",
"Ui.Settings.Common.Lock": "Bloquear",
"Ui.Settings.Common.Merge": "Combinar",
"Ui.Settings.Common.Open": "Abrir",
"Ui.Settings.Common.Overwrite": "Sobrescribir",
"Ui.Settings.Common.Perform": "Ejecutar",
"Ui.Settings.Common.ResetAll": "Restablecer todo",
"Ui.Settings.Common.ResolveAll": "Resolver todo",
"Ui.Settings.Common.Scan": "Analizar",
"Ui.Settings.Common.Send": "Enviar",
"Ui.Settings.Common.Use": "Usar",
"Ui.Settings.Common.VerifyAll": "Verificar todo",
"Ui.Settings.CustomizationSync.OpenDesc": "Abre el diálogo",
"Ui.Settings.CustomizationSync.Panel": "Sincronización de personalizaciones",
"Ui.Settings.CustomizationSync.WarnChangeDeviceName": "No se puede cambiar el nombre del dispositivo mientras esta función esté activada. Desactívala para poder cambiarlo.",
"Ui.Settings.CustomizationSync.WarnSetDeviceName": "Establece un nombre para identificar este dispositivo. Debe ser único entre tus dispositivos. Mientras no esté configurado, no se puede activar esta función.",
"Ui.Settings.Hatch.AnalyseDatabaseUsage": "Analizar el uso de la base de datos",
"Ui.Settings.Hatch.AnalyseDatabaseUsageDesc": "Analiza el uso de la base de datos y genera un informe TSV para que puedas diagnosticarlo tú mismo. Puedes pegar el informe generado en la hoja de cálculo que prefieras.",
"Ui.Settings.Hatch.BackToNonConfigured": "Volver al estado sin configurar",
"Ui.Settings.Hatch.ConvertNonObfuscated": "Comprobar y convertir los archivos sin ruta ofuscada",
"Ui.Settings.Hatch.ConvertNonObfuscatedDesc": "Comprueba si la base de datos local contiene archivos guardados sin ofuscación de ruta y los convierte si hace falta.",
"Ui.Settings.Hatch.CopyIssueReport": "Copiar el informe al portapapeles",
"Ui.Settings.Hatch.DatabaseLabel": "Base de datos: ${details}",
"Ui.Settings.Hatch.DatabaseToStorage": "Base de datos -> Almacenamiento",
"Ui.Settings.Hatch.DeleteCustomizationSyncData": "Eliminar todos los datos de la sincronización de personalizaciones",
"Ui.Settings.Hatch.GeneratedReport": "Informe generado",
"Ui.Settings.Hatch.Missing": "Falta",
"Ui.Settings.Hatch.ModifiedSize": "Modificado: ${modified}, tamaño: ${size}",
"Ui.Settings.Hatch.ModifiedSizeActual": "Modificado: ${modified}, tamaño: ${size} (tamaño real: ${actualSize})",
"Ui.Settings.Hatch.PrepareIssueReport": "Preparar el «informe» para abrir una incidencia",
"Ui.Settings.Hatch.RecoveryAndRepair": "Recuperación y reparación",
"Ui.Settings.Hatch.RecreateAll": "Recrear todo",
"Ui.Settings.Hatch.RecreateMissingChunks": "Recrear los chunks que faltan de todos los archivos",
"Ui.Settings.Hatch.RecreateMissingChunksDesc": "Recrea los chunks de todos los archivos. Si faltaban chunks, esto puede corregir los errores.",
"Ui.Settings.Hatch.ResetPanel": "Restablecer",
"Ui.Settings.Hatch.ResetRemoteUsage": "Restablecer el umbral de aviso y comprobar el uso de la base de datos remota",
"Ui.Settings.Hatch.ResetRemoteUsageDesc": "Restablece el umbral de tamaño del almacenamiento remoto y vuelve a comprobar su tamaño.",
"Ui.Settings.Hatch.ResolveAllConflictedFiles": "Resolver todos los archivos en conflicto con el más reciente",
"Ui.Settings.Hatch.ResolveAllConflictedFilesDesc": "Resuelve todos los archivos en conflicto quedándose con el más reciente. Atención: esto sobrescribe el más antiguo y no se puede recuperar.",
"Ui.Settings.Hatch.RunDoctor": "Ejecutar el Doctor",
"Ui.Settings.Hatch.ScanBrokenFiles": "Buscar archivos dañados",
"Ui.Settings.Hatch.ScramSwitches": "Interruptores de emergencia",
"Ui.Settings.Hatch.ShowHistory": "Mostrar el historial",
"Ui.Settings.Hatch.StorageLabel": "Almacenamiento: ${details}",
"Ui.Settings.Hatch.StorageToDatabase": "Almacenamiento -> Base de datos",
"Ui.Settings.Hatch.VerifyAndRepairAllFiles": "Verificar y reparar todos los archivos",
"Ui.Settings.Hatch.VerifyAndRepairAllFilesDesc": "Compara el contenido de los archivos entre la base de datos local y el almacenamiento. Si no coinciden, se te preguntará cuál conservar.",
"Ui.Settings.Maintenance.Cleanup": "Realizar limpieza",
"Ui.Settings.Maintenance.CleanupDesc": "Reduce el espacio de almacenamiento descartando todas las revisiones que no sean la última. Requiere la misma cantidad de espacio libre en el servidor remoto y en el cliente local.",
"Ui.Settings.Maintenance.DeleteLocalDatabase": "Eliminar la base de datos local para restablecer o desinstalar Self-hosted LiveSync",
"Ui.Settings.Maintenance.EmergencyRestart": "Reinicio de emergencia",
"Ui.Settings.Maintenance.EmergencyRestartDesc": "Desactiva toda la sincronización y reinicia.",
"Ui.Settings.Maintenance.FreshStartWipe": "Borrado para empezar de cero",
"Ui.Settings.Maintenance.FreshStartWipeDesc": "Elimina todos los datos del servidor remoto.",
"Ui.Settings.Maintenance.GarbageCollection": "Recolección de basura V3 (beta)",
"Ui.Settings.Maintenance.GarbageCollectionAction": "Realizar la recolección de basura",
"Ui.Settings.Maintenance.GarbageCollectionDesc": "Realiza una recolección de basura para eliminar los chunks sin usar y reducir el tamaño de la base de datos.",
"Ui.Settings.Maintenance.LockServer": "Bloquear el servidor",
"Ui.Settings.Maintenance.LockServerDesc": "Bloquea el servidor remoto para impedir la sincronización con otros dispositivos.",
"Ui.Settings.Maintenance.OverwriteRemote": "Sobrescribir el remoto",
"Ui.Settings.Maintenance.OverwriteRemoteDesc": "Sobrescribe el remoto con la base de datos local y la frase de contraseña.",
"Ui.Settings.Maintenance.OverwriteServerData": "Sobrescribir los datos del servidor con los archivos de este dispositivo",
"Ui.Settings.Maintenance.OverwriteServerDataDesc": "Reconstruye la base de datos local y la remota con los archivos de este dispositivo.",
"Ui.Settings.Maintenance.PurgeAllJournalCounter": "Purgar todos los contadores del diario",
"Ui.Settings.Maintenance.PurgeAllJournalCounterDesc": "Purga todas las cachés de descarga y de subida.",
"Ui.Settings.Maintenance.RebuildingOperations": "Operaciones de reconstrucción (solo remoto)",
"Ui.Settings.Maintenance.Resend": "Reenviar",
"Ui.Settings.Maintenance.ResendDesc": "Reenvía todos los chunks al remoto.",
"Ui.Settings.Maintenance.Reset": "Restablecer",
"Ui.Settings.Maintenance.ResetAllJournalCounter": "Restablecer todos los contadores del diario",
"Ui.Settings.Maintenance.ResetAllJournalCounterDesc": "Inicializa todo el historial del diario. En la próxima sincronización se volverán a recibir y enviar todos los elementos.",
"Ui.Settings.Maintenance.ResetJournalReceived": "Restablecer el historial de recepción del diario",
"Ui.Settings.Maintenance.ResetJournalReceivedDesc": "Inicializa el historial de recepción del diario. En la próxima sincronización se volverán a descargar todos los elementos salvo los enviados por este dispositivo.",
"Ui.Settings.Maintenance.ResetJournalSent": "Restablecer el historial de envío del diario",
"Ui.Settings.Maintenance.ResetJournalSentDesc": "Inicializa el historial de envío del diario. En la próxima sincronización se volverán a enviar todos los elementos salvo los recibidos por este dispositivo.",
"Ui.Settings.Maintenance.ResetLocalSyncInfo": "Restablecer la información de sincronización",
"Ui.Settings.Maintenance.ResetLocalSyncInfoDesc": "Restaura o reconstruye la base de datos local a partir del remoto.",
"Ui.Settings.Maintenance.ResetReceived": "Restablecer lo recibido",
"Ui.Settings.Maintenance.ResetSentHistory": "Restablecer el historial de envíos",
"Ui.Settings.Maintenance.ResetThisDevice": "Restablecer la sincronización en este dispositivo",
"Ui.Settings.Maintenance.ScheduleAndRestart": "Programar y reiniciar",
"Ui.Settings.Maintenance.Scram": "¡Parada de emergencia!",
"Ui.Settings.Maintenance.SendChunks": "Enviar los chunks",
"Ui.Settings.Maintenance.Syncing": "Sincronización",
"Ui.Settings.Maintenance.WarningLockedReadyAction": "Estoy listo, desbloquear la base de datos",
"Ui.Settings.Maintenance.WarningLockedReadyText": "Para evitar que el vault se corrompa, la base de datos remota se ha bloqueado para la sincronización. (Este dispositivo está marcado como «resuelto».) Cuando todos tus dispositivos estén marcados como «resueltos», desbloquea la base de datos. Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"Ui.Settings.Maintenance.WarningLockedResolveAction": "He hecho una copia de seguridad, marcar este dispositivo como resuelto",
"Ui.Settings.Maintenance.WarningLockedResolveText": "La base de datos remota está bloqueada para la sincronización a fin de evitar que el vault se corrompa, porque este dispositivo no está marcado como «resuelto». Haz una copia de seguridad de tu vault, restablece la base de datos local y selecciona «Marcar este dispositivo como resuelto». Este aviso seguirá apareciendo hasta que la replicación confirme que el dispositivo está resuelto.",
"Ui.Settings.Maintenance.WriteRedFlagAndRestart": "Marcar y reiniciar",
"Ui.Settings.Patches.CompatibilityConflict": "Compatibilidad (comportamiento ante conflictos)",
"Ui.Settings.Patches.CompatibilityDatabase": "Compatibilidad (estructura de la base de datos)",
"Ui.Settings.Patches.CompatibilityInternalApi": "Compatibilidad (uso de la API interna)",
"Ui.Settings.Patches.CompatibilityMetadata": "Compatibilidad (metadatos)",
"Ui.Settings.Patches.CompatibilityRemote": "Compatibilidad (base de datos remota)",
"Ui.Settings.Patches.CompatibilityTrouble": "Compatibilidad (problemas resueltos)",
"Ui.Settings.Patches.CurrentAdapter": "Adaptador actual: ${adapter}",
"Ui.Settings.Patches.DatabaseAdapter": "Adaptador de base de datos",
"Ui.Settings.Patches.DatabaseAdapterDesc": "Selecciona el adaptador de base de datos que se va a usar.",
"Ui.Settings.Patches.EdgeCaseBehaviour": "Casos límite (comportamiento)",
"Ui.Settings.Patches.EdgeCaseDatabase": "Casos límite (base de datos)",
"Ui.Settings.Patches.EdgeCaseProcessing": "Casos límite (procesamiento)",
"Ui.Settings.Patches.IndexedDbWarning": "El adaptador IndexedDB suele ofrecer mejor rendimiento en ciertos casos, pero se ha comprobado que provoca fugas de memoria con el modo LiveSync. Si usas el modo LiveSync, utiliza en su lugar el adaptador IDB.",
"Ui.Settings.Patches.MigratingToIdb": "Migrando todos los datos a IDB...",
"Ui.Settings.Patches.MigratingToIndexedDb": "Migrando todos los datos a IndexedDB...",
"Ui.Settings.Patches.MigrationIdbCompleted": "Migración a IDB completada. Obsidian se reiniciará de inmediato con la nueva configuración.",
"Ui.Settings.Patches.MigrationIdbCompletedFollowUp": "Migración a IDB completada. Cambia el adaptador y reinicia Obsidian.",
"Ui.Settings.Patches.MigrationIndexedDbCompleted": "Migración a IndexedDB completada. Obsidian se reiniciará de inmediato con la nueva configuración.",
"Ui.Settings.Patches.MigrationIndexedDbCompletedFollowUp": "Migración a IndexedDB completada. Cambia el adaptador y reinicia Obsidian.",
"Ui.Settings.Patches.MigrationWarning": "Cambiar este ajuste requiere migrar los datos existentes, lo que puede tardar un rato, y reiniciar Obsidian. Asegúrate de hacer una copia de seguridad de tus datos antes de continuar.",
"Ui.Settings.Patches.OperationToIdb": "a IDB",
"Ui.Settings.Patches.OperationToIndexedDb": "a IndexedDB",
"Ui.Settings.Patches.Remediation": "Remediación",
"Ui.Settings.Patches.RemediationChanged": "Se ha cambiado el ajuste de remediación",
"Ui.Settings.Patches.RemediationNoLimit": "Sin límite configurado",
"Ui.Settings.Patches.RemediationRestarting": "Se ha cambiado el ajuste de remediación. Reiniciando Obsidian...",
"Ui.Settings.Patches.RemediationRestartLater": "Más tarde",
"Ui.Settings.Patches.RemediationRestartMessage": "Se recomienda encarecidamente reiniciar Obsidian. Hasta que lo hagas, puede que algunos cambios no surtan efecto y que la interfaz se muestre de forma inconsistente. ¿Seguro que quieres reiniciar ahora?",
"Ui.Settings.Patches.RemediationRestartNow": "Reiniciar ahora",
"Ui.Settings.Patches.RemediationSuffixChanged": "El sufijo ha cambiado. Reabriendo la base de datos...",
"Ui.Settings.Patches.RemediationWithValue": "Límite: ${date} (${timestamp})",
"Ui.Settings.Patches.RemoteDatabaseSunset": "Ajuste fino de la base de datos remota (en desuso)",
"Ui.Settings.Patches.SwitchToIDB": "Cambiar a IDB",
"Ui.Settings.Patches.SwitchToIndexedDb": "Cambiar a IndexedDB",
"Ui.Settings.PowerUsers.ConfigurationEncryption": "Cifrado de la configuración",
"Ui.Settings.PowerUsers.ConnectionTweak": "Ajuste fino de la conexión con CouchDB",
"Ui.Settings.PowerUsers.ConnectionTweakDesc": "Si alcanzas el límite de tamaño de carga al usar IBM Cloudant, reduce el tamaño de lote y el límite de lote.",
"Ui.Settings.PowerUsers.Default": "Predeterminado",
"Ui.Settings.PowerUsers.Developer": "Desarrollo",
"Ui.Settings.PowerUsers.EncryptSensitiveConfig": "Cifrar los elementos sensibles de la configuración",
"Ui.Settings.PowerUsers.PromptPassphraseEveryLaunch": "Solicitar la frase de contraseña en cada inicio",
"Ui.Settings.PowerUsers.UseCustomPassphrase": "Usar una frase de contraseña personalizada",
"Ui.Settings.Remote.Activate": "Activar",
"Ui.Settings.Remote.ActiveSuffix": " (activo)",
"Ui.Settings.Remote.AddConnection": "Añadir conexión",
"Ui.Settings.Remote.AddRemoteDefaultName": "Remoto nuevo",
"Ui.Settings.Remote.ConfigureAndChangeRemote": "Configurar y cambiar el remoto",
"Ui.Settings.Remote.ConfigureE2EE": "Configurar el E2EE",
"Ui.Settings.Remote.ConfigureRemote": "Configurar el remoto",
"Ui.Settings.Remote.DeleteRemoteConfirm": "¿Eliminar la configuración remota «${name}»?",
"Ui.Settings.Remote.DeleteRemoteTitle": "Eliminar la configuración remota",
"Ui.Settings.Remote.DisplayName": "Nombre visible",
"Ui.Settings.Remote.DuplicateRemote": "Duplicar el remoto",
"Ui.Settings.Remote.DuplicateRemoteSuffix": "${name} (copia)",
"Ui.Settings.Remote.E2EEConfiguration": "Configuración del E2EE",
"Ui.Settings.Remote.Export": "Exportar",
"Ui.Settings.Remote.FetchRemoteSettings": "Obtener los ajustes remotos",
"Ui.Settings.Remote.ImportConnection": "Importar conexión",
"Ui.Settings.Remote.ImportConnectionPrompt": "Pega una cadena de conexión",
"Ui.Settings.Remote.ImportedCouchDb": "CouchDB importado",
"Ui.Settings.Remote.ImportedRemote": "Remoto",
"Ui.Settings.Remote.MoreActions": "Más acciones",
"Ui.Settings.Remote.PeerToPeerPanel": "Sincronización punto a punto",
"Ui.Settings.Remote.RemoteConfigurationPrefix": "Configuración remota",
"Ui.Settings.Remote.RemoteDatabases": "Bases de datos remotas",
"Ui.Settings.Remote.RemoteName": "Nombre del remoto",
"Ui.Settings.Remote.RemoteNameCouchDb": "CouchDB ${host}",
"Ui.Settings.Remote.RemoteNameP2P": "P2P ${room}",
"Ui.Settings.Remote.RemoteNameS3": "S3 ${bucket}",
"Ui.Settings.Remote.Rename": "Cambiar el nombre",
"Ui.Settings.Selector.AddDefaultPatterns": "Añadir patrones predeterminados",
"Ui.Settings.Selector.CrossPlatform": "Multiplataforma",
"Ui.Settings.Selector.Default": "Predeterminado",
"Ui.Settings.Selector.HiddenFiles": "Archivos ocultos",
"Ui.Settings.Selector.IgnorePatterns": "Patrones de exclusión",
"Ui.Settings.Selector.NonSynchronisingFiles": "Archivos que no se sincronizan",
"Ui.Settings.Selector.NonSynchronisingFilesDesc": "(RegExp) Si se establece, se omitirá cualquier cambio en archivos locales y remotos que coincida con este patrón.",
"Ui.Settings.Selector.NormalFiles": "Archivos normales",
"Ui.Settings.Selector.OverwritePatterns": "Patrones de sobrescritura",
"Ui.Settings.Selector.OverwritePatternsDesc": "Patrones de los archivos que se sobrescriben en lugar de combinarse",
"Ui.Settings.Selector.SynchronisingFiles": "Archivos que se sincronizan",
"Ui.Settings.Selector.SynchronisingFilesDesc": "(RegExp) Déjalo vacío para sincronizar todos los archivos. Define un filtro como expresión regular para limitar los archivos sincronizados.",
"Ui.Settings.Selector.TargetPatterns": "Patrones de inclusión",
"Ui.Settings.Selector.TargetPatternsDesc": "Patrones de los archivos que se van a sincronizar",
"Ui.Settings.Setup.RerunWizardButton": "Volver a ejecutar el asistente",
"Ui.Settings.Setup.RerunWizardDesc": "Vuelve a ejecutar el asistente de configuración inicial para configurar Self-hosted LiveSync de nuevo.",
"Ui.Settings.Setup.RerunWizardName": "Volver a ejecutar el asistente de configuración inicial",
"Ui.Settings.SyncSettings.Fetch": "Obtener",
"Ui.Settings.SyncSettings.Merge": "Combinar",
"Ui.Settings.SyncSettings.Overwrite": "Sobrescribir",
"Ui.SetupWizard.Common.Back": "No, volver atrás",
"Ui.SetupWizard.Common.Cancel": "Cancelar",
"Ui.SetupWizard.Common.ProceedSelectOption": "Selecciona una opción para continuar",
"Ui.SetupWizard.Intro.ExistingOption": "Estoy añadiendo un dispositivo a una configuración de sincronización existente",
"Ui.SetupWizard.Intro.ExistingOptionDesc": "Elige esto si ya usas la sincronización en otro ordenador o móvil. Usa esta opción para conectar este dispositivo a esa configuración existente.",
"Ui.SetupWizard.Intro.Guidance": "Te guiaremos con unas cuantas preguntas para simplificar la configuración de la sincronización.",
"Ui.SetupWizard.Intro.NewOption": "Lo estoy configurando por primera vez",
"Ui.SetupWizard.Intro.NewOptionDesc": "Elige esto si estás configurando este dispositivo como el primero de la sincronización.",
"Ui.SetupWizard.Intro.ProceedExisting": "Sí, quiero añadir este dispositivo a mi sincronización existente",
"Ui.SetupWizard.Intro.ProceedNew": "Sí, quiero configurar una sincronización nueva",
"Ui.SetupWizard.Intro.Question": "Primero, selecciona la opción que mejor describa tu situación actual.",
"Ui.SetupWizard.Intro.Title": "Bienvenido a Self-hosted LiveSync",
"Ui.SetupWizard.Invitation.Start": "Comenzar la configuración",
"Ui.SetupWizard.OutroAskUserMode.CompatibleOption": "El remoto ya está configurado y la configuración es compatible (o pasa a serlo con esta operación).",
"Ui.SetupWizard.OutroAskUserMode.CompatibleOptionDesc": "Si no estás seguro, elegir esta opción es arriesgado. Da por supuesto que la configuración del servidor es compatible con este dispositivo. Si no lo es, puede haber pérdida de datos. Asegúrate de entender las consecuencias.",
"Ui.SetupWizard.OutroAskUserMode.ExistingOption": "Mi servidor remoto ya está configurado. Quiero añadir este dispositivo.",
"Ui.SetupWizard.OutroAskUserMode.ExistingOptionDesc": "Al elegir esta opción, este dispositivo se unirá al servidor existente. Tendrás que obtener del servidor los datos de sincronización ya existentes.",
"Ui.SetupWizard.OutroAskUserMode.Guidance": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente hay que reconstruir la base de datos local, es decir, la información de sincronización.",
"Ui.SetupWizard.OutroAskUserMode.NewOption": "Estoy configurando un servidor nuevo por primera vez / quiero restablecer mi servidor actual.",
"Ui.SetupWizard.OutroAskUserMode.NewOptionDesc": "Al elegir esta opción, el servidor se inicializará con los datos actuales de este dispositivo. Cualquier dato existente en el servidor se sobrescribirá por completo.",
"Ui.SetupWizard.OutroAskUserMode.ProceedApplySettings": "Aplicar los ajustes",
"Ui.SetupWizard.OutroAskUserMode.ProceedNext": "Continuar al paso siguiente.",
"Ui.SetupWizard.OutroAskUserMode.Question": "Selecciona tu situación.",
"Ui.SetupWizard.OutroAskUserMode.Title": "Casi terminado: se requiere una decisión",
"Ui.SetupWizard.OutroNewP2PUser.GuidanceNotice": "En P2P no hay una copia en un servidor central que sobrescribir. Este paso prepara solo este dispositivo; mantenlo conectado cuando otro dispositivo obtenga sus datos iniciales.",
"Ui.SetupWizard.OutroNewP2PUser.GuidancePrimary": "La conexión punto a punto se ha configurado correctamente. A continuación, la base de datos local de LiveSync se construirá a partir de los archivos actuales de este Vault.",
"Ui.SetupWizard.OutroNewP2PUser.Important": "TEN EN CUENTA",
"Ui.SetupWizard.OutroNewP2PUser.Proceed": "Reiniciar y preparar este dispositivo",
"Ui.SetupWizard.OutroNewP2PUser.Question": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación de la inicialización local.",
"Ui.SetupWizard.OutroNewP2PUser.Title": "Configuración completada: preparando este dispositivo P2P",
"Ui.SetupWizard.OutroNewUser.GuidancePrimary": "La conexión con el servidor se ha configurado correctamente. Como paso siguiente, los datos de sincronización del servidor se construirán a partir de los datos actuales de este dispositivo.",
"Ui.SetupWizard.OutroNewUser.GuidanceWarning": "Tras reiniciar, los datos de este dispositivo se subirán al servidor como copia maestra. Ten en cuenta que cualquier dato no deseado que haya ahora en el servidor se sobrescribirá por completo.",
"Ui.SetupWizard.OutroNewUser.Important": "IMPORTANTE",
"Ui.SetupWizard.OutroNewUser.Proceed": "Reiniciar e inicializar el servidor",
"Ui.SetupWizard.OutroNewUser.Question": "Pulsa el botón de abajo para reiniciar y pasar a la confirmación final.",
"Ui.SetupWizard.OutroNewUser.Title": "Configuración completada: preparando la inicialización del servidor",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalReset": "Entiendo que esto restablece únicamente la base de datos de sincronización local de este dispositivo.",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmLocalResetNote": "Se usarán los archivos que hay ahora en este Vault para reconstruirla.",
"Ui.SetupWizard.RebuildEverythingP2P.ConfirmTitle": "⚠️ Confirma lo siguiente",
"Ui.SetupWizard.RebuildEverythingP2P.Guidance": "Este procedimiento descartará la base de datos local de LiveSync de este dispositivo y la reconstruirá a partir de los archivos actuales de este Vault. No elimina ni sobrescribe datos de otro dispositivo.",
"Ui.SetupWizard.RebuildEverythingP2P.Note": "Mantén este dispositivo conectado después de la inicialización para que otro dispositivo pueda obtener el Vault desde él.",
"Ui.SetupWizard.RebuildEverythingP2P.Proceed": "Lo entiendo, preparar este dispositivo",
"Ui.SetupWizard.RebuildEverythingP2P.Title": "Confirmación final: preparar este dispositivo para P2P",
"Ui.SetupWizard.SelectExisting.Guidance": "Estás añadiendo este dispositivo a una configuración de sincronización existente.",
"Ui.SetupWizard.SelectExisting.ManualOption": "Configurar un remoto manualmente",
"Ui.SetupWizard.SelectExisting.ManualOptionDesc": "Vuelve a configurar manualmente el mismo remoto que en tus otros dispositivos. Está pensado solo para usuarios avanzados.",
"Ui.SetupWizard.SelectExisting.ProceedManual": "Continuar con la configuración manual",
"Ui.SetupWizard.SelectExisting.ProceedQr": "Escanea con la cámara de este dispositivo el código QR mostrado en un dispositivo activo.",
"Ui.SetupWizard.SelectExisting.ProceedSetupUri": "Continuar con el Setup URI",
"Ui.SetupWizard.SelectExisting.QrOption": "Escanear un código QR (recomendado en móvil)",
"Ui.SetupWizard.SelectExisting.QrOptionDesc": "Escanea con la cámara de este dispositivo el código QR mostrado en un dispositivo activo.",
"Ui.SetupWizard.SelectExisting.Question": "Selecciona un método para importar los ajustes desde otro dispositivo.",
"Ui.SetupWizard.SelectExisting.SetupUriOption": "Usar un Setup URI (recomendado)",
"Ui.SetupWizard.SelectExisting.SetupUriOptionDesc": "Pega el Setup URI generado en uno de tus dispositivos activos.",
"Ui.SetupWizard.SelectExisting.Title": "Método de configuración del dispositivo",
"Ui.SetupWizard.SelectNew.Guidance": "Vamos a configurar la conexión de sincronización.",
"Ui.SetupWizard.SelectNew.ManualOption": "Configurar un remoto manualmente",
"Ui.SetupWizard.SelectNew.ManualOptionDesc": "Es una opción avanzada para quienes no tienen un Setup URI o quieren ajustar la configuración en detalle. También puedes usarla para la sincronización P2P en lugar de CouchDB o de un almacenamiento de objetos compatible con S3.",
"Ui.SetupWizard.SelectNew.ProceedManual": "Continuar con la configuración manual",
"Ui.SetupWizard.SelectNew.ProceedSetupUri": "Continuar con el Setup URI",
"Ui.SetupWizard.SelectNew.Question": "¿Cómo quieres configurar esta conexión de sincronización?",
"Ui.SetupWizard.SelectNew.SetupUriOption": "Usar un Setup URI (recomendado)",
"Ui.SetupWizard.SelectNew.SetupUriOptionDesc": "Un Setup URI es una única cadena que contiene los datos de conexión y autenticación. Cuando un script de instalación te proporciona uno, es la forma más sencilla y segura de configurarlo.",
"Ui.SetupWizard.SelectNew.Title": "Método de conexión",
"Ui.SetupWizard.SetupRemote.BucketOption": "Almacenamiento de objetos compatible con S3",
"Ui.SetupWizard.SetupRemote.BucketOptionDesc": "Sincronización mediante archivos de diario. Necesitas tener ya un servicio de almacenamiento de objetos compatible con S3, como Amazon S3, MinIO o Cloudflare R2.",
"Ui.SetupWizard.SetupRemote.CouchDbOptionDesc": "Es el método de sincronización más adecuado para el diseño actual y ofrece todas las funciones. Necesitas tener ya una instancia de CouchDB en marcha.",
"Ui.SetupWizard.SetupRemote.Guidance": "Selecciona el tipo de remoto para esta configuración de sincronización.",
"Ui.SetupWizard.SetupRemote.P2POption": "Punto a punto (P2P)",
"Ui.SetupWizard.SetupRemote.P2POptionDesc": "Permite la sincronización directa entre dispositivos. No hace falta servidor, pero ambos dispositivos deben estar conectados a la vez y algunas funciones pueden estar limitadas. Solo se necesita internet para la señalización, no para transferir los datos.",
"Ui.SetupWizard.SetupRemote.ProceedBucket": "Continuar con la configuración del almacenamiento de objetos",
"Ui.SetupWizard.SetupRemote.ProceedCouchDb": "Continuar con la configuración de CouchDB",
"Ui.SetupWizard.SetupRemote.ProceedP2P": "Continuar con la configuración de P2P",
"Ui.SetupWizard.SetupRemote.Title": "Elige un remoto de sincronización",
"Unique name between all synchronized devices. To edit this setting, please disable customization sync once.": "Nombre único entre dispositivos sincronizados. Para editarlo, desactive sincronización de personalización",
"Unless you are certain, selecting this options is bit dangerous. It assumes that the server configuration is compatible with this device. If this is not the case, data loss may occur. Please ensure you know what you are doing.": "Si no estás seguro, elegir esta opción es algo arriesgado. Da por supuesto que la configuración del servidor es compatible con este dispositivo. Si no lo es, puede haber pérdida de datos. Asegúrate de saber lo que haces.",
"Updating list...": "Actualizando lista...",
File diff suppressed because it is too large Load Diff
-1
View File
@@ -2435,4 +2435,3 @@ You should perform this operation only in exceptional circumstances, such as whe
when the server data is completely corrupted, when changes on all other
devices are no longer needed, or when the database size has become unusually
large in comparison to the Vault size.
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+57 -1
View File
@@ -24,9 +24,16 @@ import type { ReactiveSource } from "octagonal-wheels/dataobject/reactive_v2";
import type { LiveSyncBaseCore } from "@/LiveSyncBaseCore";
import { isNotFoundError } from "@vrtmrz/livesync-commonlib/compat/common/utils.doc";
import type PouchDB from "pouchdb-core";
import { promiseWithResolvers, type PromiseWithResolvers } from "octagonal-wheels/promises";
const KV_KEY_REPLICATION_RESULT_PROCESSOR_SNAPSHOT = "replicationResultProcessorSnapshot";
const REPROCESS_BATCH_SIZE = 100;
type LocalApplicationActivityOwner = {
runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: { label?: string }
): Promise<T>;
};
type ReplicateResultProcessorState = {
queued: PouchDB.Core.ExistingDocument<EntryDoc>[];
processing: PouchDB.Core.ExistingDocument<EntryDoc>[];
@@ -67,9 +74,11 @@ export class ReplicateResultProcessor {
public suspend() {
this._suspended = true;
this.updateProcessingActivity();
}
public resume() {
this._suspended = false;
this.updateProcessingActivity();
fireAndForget(() => this.runProcessQueue());
}
@@ -251,6 +260,40 @@ export class ReplicateResultProcessor {
*/
private _processingChanges: PouchDB.Core.ExistingDocument<EntryDoc>[] = [];
private _processingActivity?: Promise<void>;
private _processingActivityDone?: PromiseWithResolvers<void>;
private updateProcessingActivity() {
if (this.isSuspended) {
this._processingActivityDone?.resolve();
return;
}
const hasPendingDocuments = this._queuedChanges.length > 0 || this._processingChanges.length > 0;
if (!hasPendingDocuments) {
this._processingActivityDone?.resolve();
return;
}
if (this._processingActivity) return;
const activityDone = promiseWithResolvers<void>();
this._processingActivityDone = activityDone;
const activityOwner = this.services.replicator as typeof this.services.replicator &
Partial<LocalApplicationActivityOwner>;
this._processingActivity = (
activityOwner.runBoundedLocalApplicationActivity
? activityOwner.runBoundedLocalApplicationActivity(() => activityDone.promise, {
label: "replicated-document-application",
})
: activityDone.promise
)
.catch((error) => this.logError(error))
.finally(() => {
if (this._processingActivityDone === activityDone) this._processingActivityDone = undefined;
this._processingActivity = undefined;
this.updateProcessingActivity();
});
}
/**
* Enqueue the given document change for processing.
* @param doc Document change to enqueue
@@ -278,6 +321,7 @@ export class ReplicateResultProcessor {
}
// Enqueue the change
this._queuedChanges.push(doc);
this.updateProcessingActivity();
this.triggerTakeSnapshot();
this.triggerProcessQueue();
}
@@ -385,7 +429,19 @@ export class ReplicateResultProcessor {
} finally {
// Remove from processing queue
this._processingChanges = this._processingChanges.filter((e) => e !== change);
this.triggerTakeSnapshot();
try {
if (this._queuedChanges.length === 0 && this._processingChanges.length === 0) {
try {
await this._takeSnapshot();
} catch (error) {
this.logError(error);
}
} else {
this.triggerTakeSnapshot();
}
} finally {
this.updateProcessingActivity();
}
}
}
@@ -1,8 +1,67 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
import { describe, expect, it, vi } from "vitest";
import type { EntryDoc } from "@vrtmrz/livesync-commonlib/compat/common/types";
import { ReplicateResultProcessor } from "./ReplicateResultProcessor";
describe("ReplicateResultProcessor target-filter reprocessing", () => {
function note(id: string): PouchDB.Core.ExistingDocument<EntryDoc> {
return {
_id: id,
_rev: "1-test",
path: `${id}.md`,
ctime: 1,
mtime: 2,
size: 1,
children: [],
datatype: "plain",
type: "plain",
eden: {},
} as unknown as PouchDB.Core.ExistingDocument<EntryDoc>;
}
type SetupOptions = {
processSynchroniseResult?: (entry: unknown) => Promise<void>;
setSnapshot?: (key: string, value: unknown) => Promise<unknown>;
};
function setup(options: SetupOptions = {}) {
const processSynchroniseResult = vi.fn(options.processSynchroniseResult ?? (async () => undefined));
const setSnapshot = vi.fn(options.setSnapshot ?? (async () => undefined));
const runBoundedLocalApplicationActivity = vi.fn(async (task: () => Promise<void>) => await task());
const core = {
services: {
appLifecycle: { isReady: true, isSuspended: () => false },
path: { getPath: (entry: { path: string }) => entry.path },
replication: {
databaseQueueCount: reactiveSource(0),
storageApplyingCount: reactiveSource(0),
replicationResultCount: reactiveSource(0),
processVirtualDocument: vi.fn(async () => false),
processOptionalSynchroniseResult: vi.fn(async () => false),
processSynchroniseResult,
},
replicator: { runBoundedLocalApplicationActivity },
vault: {
isTargetFile: vi.fn(async () => true),
isFileSizeTooLarge: vi.fn(() => false),
isValidPath: vi.fn(() => true),
},
},
kvDB: { set: setSnapshot },
localDatabase: {
getRaw: vi.fn(async (id: string) => ({ _id: id, _rev: "1-test" })),
getDBEntryFromMeta: vi.fn(async (entry: object) => ({ ...entry, data: "x" })),
},
replicator: { closeReplication: vi.fn() },
};
const processor = new ReplicateResultProcessor({
core,
settings: { maxMTimeForReflectEvents: 0, suspendParseReplicationResult: false },
} as never);
return { processor, processSynchroniseResult, runBoundedLocalApplicationActivity };
}
describe("ReplicateResultProcessor", () => {
it("scans normal-file metadata without loading chunk documents and requeues it", async () => {
const documents = [
{ _id: "first", _rev: "1-a", type: "plain", path: "first.md" },
@@ -22,4 +81,67 @@ describe("ReplicateResultProcessor target-filter reprocessing", () => {
expect(enqueueAll).toHaveBeenCalledOnce();
expect(enqueueAll).toHaveBeenCalledWith(documents);
});
it("keeps one local application activity until every replicated document has been applied", async () => {
const applying = promiseWithResolvers<void>();
let activityFinished = false;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one"), note("two")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledTimes(2));
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(1);
expect(runBoundedLocalApplicationActivity).toHaveBeenCalledWith(expect.any(Function), {
label: "replicated-document-application",
});
expect(activityFinished).toBe(false);
applying.resolve();
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("settles local application activity when the final recovery snapshot fails", async () => {
let activityFinished = false;
const { processor, runBoundedLocalApplicationActivity } = setup({
setSnapshot: async () => Promise.reject(new Error("snapshot failed")),
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
activityFinished = true;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(activityFinished).toBe(true));
});
it("releases and reacquires local application activity around processing suspension", async () => {
const applying = promiseWithResolvers<void>();
let completedActivities = 0;
const { processor, processSynchroniseResult, runBoundedLocalApplicationActivity } = setup({
processSynchroniseResult: async () => applying.promise,
});
runBoundedLocalApplicationActivity.mockImplementation(async (task: () => Promise<void>) => {
await task();
completedActivities++;
});
processor.enqueueAll([note("one")]);
await vi.waitFor(() => expect(processSynchroniseResult).toHaveBeenCalledOnce());
processor.suspend();
await vi.waitFor(() => expect(completedActivities).toBe(1));
processor.resume();
await vi.waitFor(() => expect(runBoundedLocalApplicationActivity).toHaveBeenCalledTimes(2));
applying.resolve();
await vi.waitFor(() => expect(completedActivities).toBe(2));
});
});
@@ -113,9 +113,20 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
hasFocus = true;
isLastHidden = false;
private boundedRemoteActivityEndHandler?: (value: { readonly value: number }) => unknown;
private boundedActivityEndHandler?: (value: { readonly value: number }) => unknown;
private deferredBoundedLifecycle?: "suspend-if-hidden" | "restart-continuous-if-visible";
private get boundedActivityCounts(): ReactiveSource<number>[] {
const replicator = this.services.replicator as typeof this.services.replicator & {
boundedLocalApplicationActivityCount: ReactiveSource<number>;
};
return [replicator.boundedRemoteActivityCount, replicator.boundedLocalApplicationActivityCount];
}
private hasBoundedActivity() {
return this.boundedActivityCounts.some((count) => count.value > 0);
}
private keepReplicationActiveInBackground() {
return (
this.settings.keepReplicationActiveInBackground &&
@@ -125,9 +136,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
private async applyDeferredBoundedActivityLifecycle() {
const count = this.services.replicator.boundedRemoteActivityCount;
if (count.value !== 0) {
this.deferLifecycleUntilBoundedRemoteActivityEnds();
if (this.hasBoundedActivity()) {
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
const deferredLifecycle = this.deferredBoundedLifecycle;
@@ -149,17 +159,17 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
}
}
private deferLifecycleUntilBoundedRemoteActivityEnds() {
if (this.boundedRemoteActivityEndHandler) return;
const count = this.services.replicator.boundedRemoteActivityCount;
const handler = (value: { readonly value: number }) => {
if (value.value !== 0) return;
count.offChanged(handler);
this.boundedRemoteActivityEndHandler = undefined;
private deferLifecycleUntilBoundedActivityEnds() {
if (this.boundedActivityEndHandler) return;
const counts = this.boundedActivityCounts;
const handler = () => {
if (this.hasBoundedActivity()) return;
for (const count of counts) count.offChanged(handler);
this.boundedActivityEndHandler = undefined;
fireAndForget(() => this.applyDeferredBoundedActivityLifecycle());
};
this.boundedRemoteActivityEndHandler = handler;
count.onChanged(handler);
this.boundedActivityEndHandler = handler;
for (const count of counts) count.onChanged(handler);
}
setHasFocus(hasFocus: boolean) {
@@ -188,12 +198,12 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
if (
this.settings.isConfigured &&
this.services.appLifecycle.isReady() &&
this.services.replicator.boundedRemoteActivityCount.value > 0
this.hasBoundedActivity()
) {
const isHidden = activeWindow.document.hidden;
this.isLastHidden = isHidden;
this.deferredBoundedLifecycle = isHidden ? "suspend-if-hidden" : undefined;
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
}
return;
}
@@ -210,8 +220,8 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
return;
}
const boundedRemoteActivityInProgress = this.services.replicator.boundedRemoteActivityCount.value > 0;
if (!isHidden && boundedRemoteActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
const boundedActivityInProgress = this.hasBoundedActivity();
if (!isHidden && boundedActivityInProgress && this.deferredBoundedLifecycle === "suspend-if-hidden") {
this.isLastHidden = false;
this.deferredBoundedLifecycle = undefined;
return;
@@ -228,18 +238,18 @@ export class ModuleObsidianEvents extends AbstractObsidianModule {
const keepActiveInBackground = this.keepReplicationActiveInBackground();
if (isHidden) {
if (boundedRemoteActivityInProgress && !keepActiveInBackground) {
if (boundedActivityInProgress && !keepActiveInBackground) {
this.deferredBoundedLifecycle = "suspend-if-hidden";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
} else if (!keepActiveInBackground) {
await this.services.appLifecycle.onSuspending();
}
} else {
// suspend all temporary.
if (this.services.appLifecycle.isSuspended()) return;
if (boundedRemoteActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
if (boundedActivityInProgress && keepActiveInBackground && this.settings.liveSync) {
this.deferredBoundedLifecycle = "restart-continuous-if-visible";
this.deferLifecycleUntilBoundedRemoteActivityEnds();
this.deferLifecycleUntilBoundedActivityEnds();
return;
}
// Only the continuous (LiveSync) channel can go stalled-but-not-terminated: PouchDB
@@ -24,6 +24,7 @@ function setup(opts: SetupOptions) {
};
const fileProcessing = { commitPendingFileEvents: vi.fn(async () => true) };
const boundedRemoteActivityCount = reactiveSource(0);
const boundedLocalApplicationActivityCount = reactiveSource(0);
const core = {
_services: {
@@ -38,7 +39,7 @@ function setup(opts: SetupOptions) {
setting: { saveSettingData: vi.fn(async () => undefined) },
appLifecycle,
fileProcessing,
replicator: { boundedRemoteActivityCount },
replicator: { boundedRemoteActivityCount, boundedLocalApplicationActivityCount },
},
settings: {
...DEFAULT_SETTINGS,
@@ -56,7 +57,13 @@ function setup(opts: SetupOptions) {
// The handler reads `activeWindow.document.hidden`.
(globalThis as any).activeWindow = { document: { hidden: opts.hidden } };
return { module, appLifecycle, fileProcessing, boundedRemoteActivityCount };
return {
module,
appLifecycle,
fileProcessing,
boundedRemoteActivityCount,
boundedLocalApplicationActivityCount,
};
}
describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", () => {
@@ -109,6 +116,21 @@ describe("watchWindowVisibilityAsync — keepReplicationActiveInBackground", ()
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers suspension while local document application is active", async () => {
const { module, appLifecycle, boundedLocalApplicationActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
hidden: true,
});
boundedLocalApplicationActivityCount.value = 1;
await module.watchWindowVisibilityAsync();
expect(appLifecycle.onSuspending).not.toHaveBeenCalled();
boundedLocalApplicationActivityCount.value = 0;
await vi.waitFor(() => expect(appLifecycle.onSuspending).toHaveBeenCalledTimes(1));
});
it("defers mobile suspension while bounded remote activity is running", async () => {
const { module, appLifecycle, boundedRemoteActivityCount } = setup({
settings: { keepReplicationActiveInBackground: false, liveSync: false },
@@ -74,6 +74,11 @@ export class DocumentHistoryModal extends Modal {
currentDeleted = false;
initialRev?: string;
// Revision navigation state (◀/▶ beside the range slider)
revPrevBtn!: HTMLButtonElement;
revNextBtn!: HTMLButtonElement;
revNavIndicator!: HTMLSpanElement;
// Diff navigation state
currentDiffIndex = -1;
diffNavContainer!: HTMLDivElement;
@@ -84,6 +89,8 @@ export class DocumentHistoryModal extends Modal {
searchKeyword = "";
searchResults: { rev: string; index: number; matchType: "Content" | "Diff" }[] = [];
currentSearchIndex = -1;
searchPrevBtn!: HTMLButtonElement;
searchNextBtn!: HTMLButtonElement;
searchResultIndicator!: HTMLSpanElement;
searchProgressIndicator!: HTMLSpanElement;
searchTimeout: number | null = null;
@@ -125,12 +132,14 @@ export class DocumentHistoryModal extends Modal {
this.range.value = this.range.max;
this.fileInfo.setText(`${this.file} / ${this.revs_info.length} revisions`);
await this.loadRevs(initialRev);
this.updateRevisionNavUI();
} catch (ex) {
if (isErrorOfMissingDoc(ex)) {
this.range.max = "0";
this.range.value = "";
this.range.disabled = true;
this.contentView.setText(`We don't have any history for this note.`);
this.updateRevisionNavUI();
} else {
this.contentView.setText(`Error while loading file.`);
Logger(ex, LOG_LEVEL_VERBOSE);
@@ -148,6 +157,37 @@ export class DocumentHistoryModal extends Modal {
const index = this.revs_info.length - 1 - (Number(this.range.value) || 0);
const rev = this.revs_info[index];
await this.showExactRev(rev.rev);
this.updateRevisionNavUI();
}
navigateVersion(direction: "older" | "newer") {
const current = Number(this.range.value) || 0;
const max = Number(this.range.max) || 0;
if (direction === "older" && current > 0) {
this.range.value = `${current - 1}`;
} else if (direction === "newer" && current < max) {
this.range.value = `${current + 1}`;
} else {
return;
}
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
}
updateRevisionNavUI() {
if (!this.revNavIndicator) return;
const total = this.revs_info.length;
const max = Number(this.range.max) || 0;
const current = Number(this.range.value) || 0;
this.revNavIndicator.setText(total > 0 ? `Rev ${current + 1}/${total}` : "\u2014");
const disabled = !!this.range.disabled || total <= 1;
this.revPrevBtn.disabled = disabled || current <= 0;
this.revNextBtn.disabled = disabled || current >= max;
}
BlobURLs = new Map<string, string>();
@@ -391,6 +431,7 @@ export class DocumentHistoryModal extends Modal {
if (!keyword) {
this.searchResultIndicator.setText("");
this.searchProgressIndicator.setText("");
this.updateSearchUI();
return;
}
@@ -464,6 +505,10 @@ export class DocumentHistoryModal extends Modal {
const current = this.currentSearchIndex >= 0 ? this.currentSearchIndex + 1 : 0;
this.searchResultIndicator.setText(`${current}/${this.searchResults.length} matches`);
}
const hasResults = this.searchResults.length > 0;
this.searchPrevBtn.disabled = !hasResults;
this.searchNextBtn.disabled = !hasResults;
}
navigateSearch(direction: "prev" | "next") {
@@ -515,12 +560,14 @@ export class DocumentHistoryModal extends Modal {
}, 500);
});
searchRow.createEl("button", { text: "\u25B2" }, (e) => {
this.searchPrevBtn = searchRow.createEl("button", { text: "\u25B2" }, (e) => {
e.title = "Previous match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("prev"));
});
searchRow.createEl("button", { text: "\u25BC" }, (e) => {
this.searchNextBtn = searchRow.createEl("button", { text: "\u25BC" }, (e) => {
e.title = "Next match";
e.disabled = true;
e.addEventListener("click", () => this.navigateSearch("next"));
});
@@ -530,18 +577,37 @@ export class DocumentHistoryModal extends Modal {
this.searchProgressIndicator = searchRow.createSpan({ text: "" });
this.searchProgressIndicator.addClass("history-search-progress-indicator");
const divView = contentEl.createDiv("");
divView.addClass("op-flex");
const revNavRow = contentEl.createDiv({ cls: "history-rev-nav-row" });
divView.createEl("input", { type: "range" }, (e) => {
this.revPrevBtn = revNavRow.createEl("button", { text: "\u25C0" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Older revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("older"));
});
revNavRow.createEl("input", { type: "range" }, (e) => {
this.range = e;
e.addEventListener("change", (e) => {
e.addEventListener("change", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
e.addEventListener("input", (e) => {
e.addEventListener("input", () => {
this.updateRevisionNavUI();
void scheduleOnceIfDuplicated("loadRevs", () => this.loadRevs());
});
});
this.revNextBtn = revNavRow.createEl("button", { text: "\u25B6" }, (e) => {
e.addClass("history-rev-nav-btn");
e.title = "Newer revision";
e.disabled = true;
e.addEventListener("click", () => this.navigateVersion("newer"));
});
this.revNavIndicator = revNavRow.createSpan({ text: "\u2014" }, (e) => {
e.addClass("history-rev-indicator");
});
const diffOptionsRow = contentEl.createDiv("");
diffOptionsRow.addClass("op-info");
diffOptionsRow.addClass("diff-options-row");
+22 -1
View File
@@ -10,11 +10,32 @@ import { ConfigServiceBrowserCompat } from "@vrtmrz/livesync-commonlib/compat/se
import type { ObsidianServiceContext } from "@/modules/services/ObsidianServiceContext";
import { KeyValueDBService } from "@vrtmrz/livesync-commonlib/compat/services/base/KeyValueDBService";
import { ControlService } from "@vrtmrz/livesync-commonlib/compat/services/base/ControlService";
import { reactiveSource } from "octagonal-wheels/dataobject/reactive";
type ActivityOptions = {
label?: string;
};
export class ObsidianDatabaseEventService extends InjectableDatabaseEventService<ObsidianServiceContext> {}
// InjectableReplicatorService
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {}
export class ObsidianReplicatorService extends InjectableReplicatorService<ObsidianServiceContext> {
readonly boundedLocalApplicationActivityCount = reactiveSource(0);
async runBoundedLocalApplicationActivity<T>(
task: () => T | PromiseLike<T>,
options?: ActivityOptions
): Promise<T> {
this.boundedLocalApplicationActivityCount.value++;
try {
return this.dependencies.activityRunner
? await this.dependencies.activityRunner.run(task, options)
: await task();
} finally {
this.boundedLocalApplicationActivityCount.value--;
}
}
}
// InjectableFileProcessingService
export class ObsidianFileProcessingService extends InjectableFileProcessingService<ObsidianServiceContext> {}
// InjectableReplicationService
@@ -0,0 +1,35 @@
import { promiseWithResolvers } from "octagonal-wheels/promises";
import { describe, expect, it, vi } from "vitest";
import { ObsidianReplicatorService } from "./ObsidianServices";
function handler() {
return { addHandler: vi.fn() };
}
describe("ObsidianReplicatorService", () => {
it("tracks local application activity without extending remote activity", async () => {
const activity = promiseWithResolvers<void>();
const service = new ObsidianReplicatorService({ events: {}, translate: String } as never, {
settingService: { onRealiseSetting: handler() },
appLifecycleService: { onSuspending: handler(), getUnresolvedMessages: handler() },
databaseEventService: {
onResetDatabase: handler(),
onDatabaseInitialisation: handler(),
onDatabaseInitialised: handler(),
onDatabaseHasReady: handler(),
},
activityRunner: { run: vi.fn(async (task: () => Promise<void>) => await task()) },
} as never);
const running = service.runBoundedLocalApplicationActivity(() => activity.promise);
expect(service.boundedLocalApplicationActivityCount.value).toBe(1);
expect(service.boundedRemoteActivityCount.value).toBe(0);
activity.resolve();
await running;
expect(service.boundedLocalApplicationActivityCount.value).toBe(0);
expect(service.boundedRemoteActivityCount.value).toBe(0);
});
});
@@ -10,7 +10,8 @@ export class ObsidianVaultAdapter implements IVaultAdapter<TFile, TFolder> {
constructor(private app: App) {}
async read(file: TFile): Promise<string> {
return await this.app.vault.read(file);
// Vault.read strips a leading UTF-8 BOM, leaving the content size inconsistent with TFile.stat.
return await this.app.vault.adapter.read(file.path);
}
async cachedRead(file: TFile): Promise<string> {
@@ -0,0 +1,37 @@
import { describe, expect, it, vi } from "vitest";
import type { App, TFile } from "obsidian";
import { ObsidianVaultAdapter } from "./ObsidianVaultAdapter";
describe("ObsidianVaultAdapter.read", () => {
it("preserves a UTF-8 BOM so the content size matches the file stat", async () => {
const path = "Transcripts/字幕.md";
const contentWithoutBom = "字幕の検証行です。\n";
const contentWithBom = `\ufeff${contentWithoutBom}`;
const read = vi.fn().mockResolvedValue(contentWithoutBom);
const adapterRead = vi.fn().mockResolvedValue(contentWithBom);
const app = {
vault: {
read,
adapter: {
read: adapterRead,
},
},
} as unknown as App;
const file = {
path,
stat: {
ctime: 1,
mtime: 2,
size: new Blob([contentWithBom]).size,
},
} as TFile;
const adapter = new ObsidianVaultAdapter(app);
const result = await adapter.read(file);
expect(new Blob([result]).size).toBe(file.stat.size);
expect(result.charCodeAt(0)).toBe(0xfeff);
expect(adapterRead).toHaveBeenCalledWith(path);
expect(read).not.toHaveBeenCalled();
});
});
+45
View File
@@ -748,6 +748,51 @@ body.is-mobile .livesync-compatibility-review-notice {
color: var(--text-muted);
}
.history-search-row button:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.history-rev-nav-row {
display: flex;
align-items: center;
gap: 6px;
margin-bottom: 8px;
}
.history-rev-nav-row input[type="range"] {
flex-grow: 1;
margin-bottom: 0;
}
.history-rev-nav-btn {
flex-shrink: 0;
padding: 2px 10px;
font-size: 0.9em;
cursor: pointer;
border: 1px solid var(--background-modifier-border);
border-radius: 4px;
background-color: var(--background-secondary);
color: var(--text-normal);
}
.history-rev-nav-btn:hover:not(:disabled) {
background-color: var(--background-modifier-hover);
}
.history-rev-nav-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
.history-rev-indicator {
flex-shrink: 0;
font-size: 0.85em;
color: var(--text-muted);
min-width: 4.5em;
text-align: right;
}
.history-diff-options-row {
justify-content: space-between;
}
@@ -0,0 +1,293 @@
import { mkdir, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { evalObsidianJson } from "../runner/cli.ts";
import { discoverObsidianCli, requireObsidianBinary } from "../runner/environment.ts";
import { createE2eObsidianDeviceLocalState, waitForLiveSyncCoreReady } from "../runner/liveSyncWorkflow.ts";
import { startObsidianLiveSyncSession, type ObsidianLiveSyncSession } from "../runner/session.ts";
import { withObsidianPage } from "../runner/ui.ts";
import { createTemporaryVault } from "../runner/vault.ts";
process.env.E2E_OBSIDIAN_CLI_TIMEOUT_MS ??= "60000";
process.env.E2E_OBSIDIAN_CORE_READY_TIMEOUT_MS ??= "60000";
process.env.E2E_OBSIDIAN_LOCAL_DB_TIMEOUT_MS ??= "30000";
const notePath = "E2E/document-history-nav.md";
const revisions = ["Version one alpha", "Version two beta keyword", "Version three gamma"];
type RevisionInfo = {
revCount: number;
id: string;
};
type OpenHistoryResult = {
opened: boolean;
modalTitle: string | null;
};
function assertEqual(actual: unknown, expected: unknown, message: string): void {
if (actual !== expected) {
throw new Error(`${message}\nExpected: ${String(expected)}\nActual: ${String(actual)}`);
}
}
function assertTrue(value: boolean, message: string): void {
if (!value) {
throw new Error(message);
}
}
async function dismissWelcomeWizard(port: number): Promise<void> {
await withObsidianPage(port, async (page) => {
const cancel = page.getByText("No, please take me back");
if (await cancel.isVisible({ timeout: 5000 }).catch(() => false)) {
await cancel.click();
await page.waitForTimeout(500);
}
});
}
async function seedRevisions(cliBinary: string, env: NodeJS.ProcessEnv): Promise<RevisionInfo> {
return await evalObsidianJson<RevisionInfo>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
`const revisions=${JSON.stringify(revisions)};`,
"const core=app.plugins.plugins['obsidian-livesync'].core;",
"const sleep=(ms)=>new Promise((resolve)=>setTimeout(resolve,ms));",
"if(!(await app.vault.adapter.exists('E2E'))) await app.vault.createFolder('E2E');",
"const existing=app.vault.getAbstractFileByPath(path);",
"if(existing) await app.vault.delete(existing);",
"const id=await core.services.path.path2id(path);",
"let baseRev='';",
"for(const content of revisions){",
" const blob=new Blob([content],{type:'text/plain'});",
" const now=Date.now();",
" const result=await core.localDatabase.putDBEntry({",
" _id:id,",
" path,",
" data:blob,",
" ctime:now,",
" mtime:now,",
" size:(await blob.arrayBuffer()).byteLength,",
" children:[],",
" datatype:'plain',",
" type:'plain',",
" eden:{},",
" },false,baseRev||undefined);",
" if(!result?.ok) throw new Error(`Could not store revision for ${path}`);",
" baseRev=result.rev;",
" await sleep(100);",
"}",
`await app.vault.create(path,revisions[revisions.length-1]);`,
"await core.services.fileProcessing.commitPendingFileEvents();",
"const raw=await core.localDatabase.getRaw(id,{revs_info:true});",
"const revCount=(raw._revs_info||[]).filter((e)=>e&&e.status==='available').length;",
"return JSON.stringify({revCount,id});",
"})()",
].join(""),
env
);
}
async function openDocumentHistory(cliBinary: string, env: NodeJS.ProcessEnv): Promise<OpenHistoryResult> {
return await evalObsidianJson<OpenHistoryResult>(
cliBinary,
[
"(async()=>{",
`const path=${JSON.stringify(notePath)};`,
"const file=app.vault.getAbstractFileByPath(path);",
"if(!file) throw new Error('Note missing before opening history');",
"document.querySelectorAll('.modal-close-button').forEach((btn)=>btn.click());",
"await new Promise((resolve)=>setTimeout(resolve,300));",
"const leaf=app.workspace.getLeaf(false);",
"await leaf.openFile(file);",
"await new Promise((resolve)=>setTimeout(resolve,300));",
"await app.commands.executeCommandById('obsidian-livesync:livesync-history');",
"await new Promise((resolve)=>setTimeout(resolve,500));",
"const modal=document.querySelector('.modal-container .modal-title');",
"return JSON.stringify({opened:!!modal,modalTitle:modal?modal.textContent:null});",
"})()",
].join(""),
env
);
}
async function main(): Promise<void> {
const binary = requireObsidianBinary();
const cli = discoverObsidianCli();
if (!cli.binary) throw new Error(`Could not find obsidian-cli. Checked paths: ${cli.checked.join(", ")}`);
const vault = await createTemporaryVault();
let session: ObsidianLiveSyncSession | undefined;
const screenshotDir =
process.env.E2E_OBSIDIAN_HISTORY_SCREENSHOT_DIR ??
join(process.env.E2E_OBSIDIAN_DIAGNOSTICS_DIR ?? "/tmp/obsidian-livesync-e2e", "document-history-nav");
const reportPath = process.env.E2E_OBSIDIAN_HISTORY_REPORT ?? join(screenshotDir, "report.txt");
async function captureStep(page: import("playwright").Page, step: string): Promise<string> {
await mkdir(screenshotDir, { recursive: true });
const path = join(screenshotDir, `${step}.png`);
await page.screenshot({ path, fullPage: true });
console.log(`Screenshot: ${path}`);
return path;
}
try {
console.log(`Using Obsidian executable: ${binary}`);
console.log(`Temporary vault: ${vault.path}`);
session = await startObsidianLiveSyncSession({
binary,
cliBinary: cli.binary,
vault,
startupGraceMs: Number(process.env.E2E_OBSIDIAN_STARTUP_GRACE_MS ?? 1000),
pluginData: {
doctorProcessedVersion: "1.0.0",
isConfigured: true,
liveSync: false,
remoteType: "",
couchDB_URI: "",
couchDB_DBNAME: "",
couchDB_USER: "",
couchDB_PASSWORD: "",
remoteConfigurations: {},
activeConfigurationId: "",
notifyThresholdOfRemoteStorageSize: -1,
periodicReplication: false,
syncAfterMerge: false,
syncOnEditorSave: false,
syncOnFileOpen: false,
syncOnSave: false,
syncOnStart: false,
},
localStorageEntries: createE2eObsidianDeviceLocalState(vault.name),
});
await waitForLiveSyncCoreReady(cli.binary, session.cliEnv);
await dismissWelcomeWizard(session.remoteDebuggingPort);
const revisionInfo = await seedRevisions(cli.binary, session.cliEnv);
console.log(`Seeded local history: ${revisionInfo.revCount} revisions for ${revisionInfo.id}`);
assertEqual(revisionInfo.revCount, revisions.length, "Unexpected number of seeded revisions.");
const opened = await openDocumentHistory(cli.binary, session.cliEnv);
assertEqual(opened.opened, true, "Document History modal did not open.");
assertEqual(opened.modalTitle, "Document History", "Unexpected modal title.");
const report = await withObsidianPage(session.remoteDebuggingPort, async (page) => {
const modal = page.locator(".modal-container").filter({ hasText: "Document History" });
await modal.waitFor({ state: "visible", timeout: 10000 });
const revNavRow = modal.locator(".history-rev-nav-row");
await revNavRow.waitFor({ state: "visible", timeout: 10000 });
const indicator = revNavRow.locator(".history-rev-indicator");
const initialIndicator = (await indicator.innerText()).trim();
const prevBtn = revNavRow.locator(".history-rev-nav-btn").first();
const nextBtn = revNavRow.locator(".history-rev-nav-btn").last();
const range = revNavRow.locator('input[type="range"]');
assertTrue(/Rev \d+\/\d+/.test(initialIndicator), `Unexpected initial indicator: ${initialIndicator}`);
const initialRange = await range.inputValue();
const screenshotPaths: string[] = [];
screenshotPaths.push(await captureStep(page, "01-initial-latest-rev"));
assertEqual(initialIndicator, "Rev 3/3", "History did not open at the latest revision.");
assertEqual(initialRange, "2", "History slider did not open at the latest revision.");
assertTrue(
!(await prevBtn.isDisabled()),
"Older revision button should be enabled at the latest revision."
);
assertTrue(await nextBtn.isDisabled(), "Newer revision button should be disabled at the latest revision.");
await prevBtn.click();
await page.waitForTimeout(1000);
const afterPrevIndicator = (await indicator.innerText()).trim();
const afterPrevRange = await range.inputValue();
assertTrue(
Number(afterPrevRange) < Number(initialRange),
`◀ did not move to an older revision. before=${initialRange}, after=${afterPrevRange}`
);
assertTrue(afterPrevIndicator !== initialIndicator, "◀ did not update Rev indicator.");
assertTrue(!(await prevBtn.isDisabled()), "Older revision button was disabled before the oldest revision.");
assertTrue(!(await nextBtn.isDisabled()), "Newer revision button was not enabled after moving backwards.");
screenshotPaths.push(await captureStep(page, "02-after-click-older-rev"));
await prevBtn.click();
await page.waitForTimeout(1000);
assertEqual(await range.inputValue(), "0", "◀ did not reach the oldest revision.");
assertTrue(await prevBtn.isDisabled(), "Older revision button should be disabled at the oldest revision.");
assertTrue(
!(await nextBtn.isDisabled()),
"Newer revision button should be enabled at the oldest revision."
);
screenshotPaths.push(await captureStep(page, "03-at-oldest-rev"));
await nextBtn.click();
await page.waitForTimeout(1000);
await nextBtn.click();
await page.waitForTimeout(1000);
const afterNextIndicator = (await indicator.innerText()).trim();
const afterNextRange = await range.inputValue();
assertEqual(afterNextRange, initialRange, "▶ did not return to the original revision.");
assertEqual(afterNextIndicator, initialIndicator, "▶ did not restore the Rev indicator.");
assertTrue(
await nextBtn.isDisabled(),
"Newer revision button should be disabled after returning to latest."
);
screenshotPaths.push(await captureStep(page, "04-after-click-newer-rev"));
const searchInput = modal.locator(".history-search-input");
await searchInput.fill("keyword");
await page.waitForTimeout(1500);
const searchIndicator = modal.locator(".history-search-result-indicator");
const searchText = (await searchIndicator.innerText()).trim();
assertTrue(/matches/.test(searchText), `Search indicator did not report matches: ${searchText}`);
screenshotPaths.push(await captureStep(page, "05-after-search-keyword"));
const searchPrev = modal.locator(".history-search-row button").nth(0);
const searchNext = modal.locator(".history-search-row button").nth(1);
assertTrue(!(await searchPrev.isDisabled()), "Search ▲ should be enabled when matches exist.");
assertTrue(!(await searchNext.isDisabled()), "Search ▼ should be enabled when matches exist.");
await searchNext.click();
await page.waitForTimeout(1000);
const afterSearchIndicator = (await searchIndicator.innerText()).trim();
assertTrue(
/1\/\d+ matches/.test(afterSearchIndicator),
`Search navigation failed: ${afterSearchIndicator}`
);
screenshotPaths.push(await captureStep(page, "06-after-search-next-match"));
return [
`initialIndicator: ${initialIndicator}`,
`afterPrevIndicator: ${afterPrevIndicator}`,
`afterNextIndicator: ${afterNextIndicator}`,
`searchIndicator: ${searchText}`,
`afterSearchIndicator: ${afterSearchIndicator}`,
"",
"Screenshots:",
...screenshotPaths.map((p) => `- ${p}`),
].join("\n");
});
await writeFile(reportPath, report, "utf-8");
console.log(`Document History UI test passed.`);
console.log(`Report: ${reportPath}`);
console.log(`Screenshots: ${screenshotDir}`);
} finally {
if (session) await session.app.stop();
await vault.dispose();
}
}
main().catch((error: unknown) => {
console.error(error instanceof Error ? error.stack : error);
process.exit(1);
});
+1
View File
@@ -9,6 +9,7 @@ const focusedScenarios = new Set([
"onboarding-invitation",
"dialog-mounts",
"revision-repair",
"document-history-nav",
"settings-ui",
"review-harness",
"p2p-pane",
+4
View File
@@ -12,6 +12,10 @@ Earlier releases remain available in the 0.25 release history and the legacy rel
## Unreleased
### Improved
- Downloaded document batches retain best-effort screen-awake and lifecycle protection until every queued file has been applied to local storage, without extending the remote-activity indicator.
## 1.0.1
29th July, 2026