- Sync All button: downloads all pages from all spaces recursively - Skips already-cached pages (checks updatedAt to avoid re-downloading) - Progress dialog shows space/page counter during sync - Cancel support via dialog cancel button - Clean Cache button: removes all downloaded pages with confirmation - Preserves local drafts when cleaning cache - Added to Settings > Offline section
475 lines
17 KiB
Kotlin
475 lines
17 KiB
Kotlin
package com.docmost.app.api
|
|
|
|
import android.content.Context
|
|
import android.net.Uri
|
|
import android.util.Log
|
|
import com.docmost.app.BuildConfig
|
|
import com.docmost.app.utils.NetworkConnectivityObserver
|
|
import com.docmost.app.data.PageCacheManager
|
|
import com.docmost.app.data.SpaceCacheManager
|
|
import com.docmost.app.data.SyncManager
|
|
import java.io.File
|
|
import java.io.IOException
|
|
|
|
class CachedApi(
|
|
private val api: DocmostApi,
|
|
private val pageCache: PageCacheManager,
|
|
private val spaceCache: SpaceCacheManager,
|
|
private val syncManager: SyncManager,
|
|
private val networkObserver: NetworkConnectivityObserver
|
|
) {
|
|
var forcedOffline: Boolean = false
|
|
var isSyncing: Boolean = false
|
|
private set
|
|
|
|
val isOffline: Boolean
|
|
get() = forcedOffline || networkObserver.isOffline()
|
|
|
|
data class SyncProgress(
|
|
val spaceName: String,
|
|
val spaceIndex: Int,
|
|
val totalSpaces: Int,
|
|
val pageTitle: String,
|
|
val pageCount: Int,
|
|
val totalPages: Int,
|
|
val skipped: Int,
|
|
val errors: Int
|
|
)
|
|
|
|
private fun <T> cacheOrThrow(block: () -> T): T {
|
|
return try {
|
|
block()
|
|
} catch (e: Exception) {
|
|
throw OfflineNotAvailableException("No disponible sin conexión: ${e.message}")
|
|
}
|
|
}
|
|
|
|
// ── Spaces ──
|
|
|
|
suspend fun getSpaces(): List<Space> {
|
|
if (isOffline) {
|
|
val cached = spaceCache.getCachedSpaces()
|
|
if (cached.isNotEmpty()) return cached
|
|
throw OfflineNotAvailableException("No hay espacios en caché")
|
|
}
|
|
val spaces = api.getSpaces()
|
|
spaceCache.cacheSpaces(spaces)
|
|
return spaces
|
|
}
|
|
|
|
suspend fun createSpace(name: String, slug: String, description: String?): Space {
|
|
if (isOffline) {
|
|
val tempId = "temp_${System.currentTimeMillis()}"
|
|
syncManager.enqueueCreateSpace(name, slug, description, tempId)
|
|
val localSpace = Space(
|
|
id = tempId,
|
|
name = name,
|
|
description = description
|
|
)
|
|
spaceCache.cacheSpace(localSpace)
|
|
return localSpace
|
|
}
|
|
val space = api.createSpace(name, slug, description)
|
|
spaceCache.cacheSpace(space)
|
|
return space
|
|
}
|
|
|
|
suspend fun updateSpace(spaceId: String, name: String?, description: String?): Space {
|
|
if (isOffline) {
|
|
syncManager.enqueueUpdateSpace(spaceId, name, description)
|
|
val cached = spaceCache.getCachedSpace(spaceId)
|
|
if (cached != null) {
|
|
val updated = cached.copy(
|
|
name = name ?: cached.name,
|
|
description = description ?: cached.description
|
|
)
|
|
spaceCache.cacheSpace(updated)
|
|
return updated
|
|
}
|
|
throw OfflineNotAvailableException("Espacio no encontrado en caché")
|
|
}
|
|
val space = api.updateSpace(spaceId, name, description)
|
|
spaceCache.cacheSpace(space)
|
|
return space
|
|
}
|
|
|
|
suspend fun deleteSpace(spaceId: String) {
|
|
if (isOffline) {
|
|
syncManager.enqueueDeleteSpace(spaceId)
|
|
spaceCache.removeCachedSpace(spaceId)
|
|
return
|
|
}
|
|
api.deleteSpace(spaceId)
|
|
spaceCache.removeCachedSpace(spaceId)
|
|
}
|
|
|
|
suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String {
|
|
if (isOffline) {
|
|
throw OfflineNotAvailableException("Subir iconos no disponible sin conexión")
|
|
}
|
|
return api.uploadSpaceIcon(spaceId, imageUri, context)
|
|
}
|
|
|
|
suspend fun removeSpaceIcon(spaceId: String) {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
api.removeSpaceIcon(spaceId)
|
|
}
|
|
|
|
// ── Pages ──
|
|
|
|
suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> {
|
|
if (isOffline) {
|
|
val cached = pageCache.getCachedPagesForSpace(spaceId)
|
|
if (cached != null) return cached
|
|
return emptyList()
|
|
}
|
|
val pages = api.getSidebarPages(spaceId, pageId)
|
|
pageCache.cachePagesForSpace(spaceId, pages)
|
|
return pages
|
|
}
|
|
|
|
suspend fun getPageInfo(pageId: String): Page {
|
|
if (isOffline) {
|
|
val cached = pageCache.getCachedPage(pageId)
|
|
if (cached != null) return cached
|
|
throw OfflineNotAvailableException("Página no encontrada en caché")
|
|
}
|
|
val page = api.getPageInfo(pageId)
|
|
pageCache.cachePage(page)
|
|
return page
|
|
}
|
|
|
|
suspend fun createPage(title: String, spaceId: String): Page {
|
|
if (isOffline) {
|
|
val tempId = "temp_${System.currentTimeMillis()}"
|
|
syncManager.enqueueCreatePage(title, spaceId, tempId)
|
|
val localPage = Page(
|
|
id = tempId,
|
|
title = title,
|
|
spaceId = spaceId
|
|
)
|
|
pageCache.cachePage(localPage)
|
|
val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf()
|
|
existing.add(localPage)
|
|
pageCache.cachePagesForSpace(spaceId, existing)
|
|
return localPage
|
|
}
|
|
val page = api.createPage(title, spaceId)
|
|
pageCache.cachePage(page)
|
|
val existing = pageCache.getCachedPagesForSpace(spaceId)?.toMutableList() ?: mutableListOf()
|
|
existing.add(page)
|
|
pageCache.cachePagesForSpace(spaceId, existing)
|
|
return page
|
|
}
|
|
|
|
suspend fun updatePage(pageId: String, content: String?, title: String?, icon: String? = null, isJson: Boolean = false): Page {
|
|
if (isOffline) {
|
|
syncManager.enqueueUpdatePage(pageId, content ?: "", title ?: "", icon)
|
|
val cached = pageCache.getCachedPage(pageId)
|
|
if (cached != null) {
|
|
val updated = cached.copy(
|
|
title = title ?: cached.title,
|
|
icon = icon ?: cached.icon,
|
|
content = content ?: cached.content
|
|
)
|
|
pageCache.cachePage(updated)
|
|
return updated
|
|
}
|
|
throw OfflineNotAvailableException("Página no encontrada en caché")
|
|
}
|
|
val page = api.updatePage(pageId, content, title, icon, isJson)
|
|
pageCache.cachePage(page)
|
|
return page
|
|
}
|
|
|
|
suspend fun deletePage(pageId: String, spaceId: String? = null) {
|
|
if (isOffline) {
|
|
syncManager.enqueueDeletePage(pageId)
|
|
pageCache.removeFromCache(pageId)
|
|
if (spaceId != null) {
|
|
val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId }
|
|
if (cached != null) pageCache.cachePagesForSpace(spaceId, cached)
|
|
}
|
|
return
|
|
}
|
|
api.deletePage(pageId)
|
|
pageCache.removeFromCache(pageId)
|
|
if (spaceId != null) {
|
|
val cached = pageCache.getCachedPagesForSpace(spaceId)?.filter { it.id != pageId }
|
|
if (cached != null) pageCache.cachePagesForSpace(spaceId, cached)
|
|
}
|
|
}
|
|
|
|
suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page {
|
|
if (isOffline) throw OfflineNotAvailableException("Duplicar no disponible sin conexión")
|
|
|
|
val page = api.duplicatePage(pageId, targetSpaceId)
|
|
pageCache.cachePage(page)
|
|
return page
|
|
}
|
|
|
|
suspend fun movePageToSpace(pageId: String, spaceId: String) {
|
|
if (isOffline) {
|
|
syncManager.enqueueUpdatePage(pageId, "", "", null)
|
|
return
|
|
}
|
|
api.movePageToSpace(pageId, spaceId)
|
|
}
|
|
|
|
suspend fun movePageToPage(pageId: String, parentPageId: String?, position: String) {
|
|
if (isOffline) {
|
|
syncManager.enqueueUpdatePage(pageId, "", "", null)
|
|
return
|
|
}
|
|
api.movePageToPage(pageId, parentPageId, position)
|
|
}
|
|
|
|
// ── Recent ──
|
|
|
|
suspend fun getRecentPages(): List<Page> {
|
|
if (isOffline) throw OfflineNotAvailableException("Recientes no disponible sin conexión")
|
|
return api.getRecentPages()
|
|
}
|
|
|
|
// ── Shares ──
|
|
|
|
suspend fun getShares(): List<Share> {
|
|
if (isOffline) throw OfflineNotAvailableException("Compartir no disponible sin conexión")
|
|
return api.getShares()
|
|
}
|
|
|
|
suspend fun createShare(pageId: String): Share {
|
|
if (isOffline) throw OfflineNotAvailableException("Crear enlaces no disponible sin conexión")
|
|
return api.createShare(pageId)
|
|
}
|
|
|
|
suspend fun deleteShare(shareId: String) {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
api.deleteShare(shareId)
|
|
}
|
|
|
|
// ── Comments ──
|
|
|
|
suspend fun getComments(pageId: String): List<Comment> {
|
|
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
|
|
return api.getComments(pageId)
|
|
}
|
|
|
|
suspend fun createComment(pageId: String, content: String): Comment {
|
|
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
|
|
return api.createComment(pageId, content)
|
|
}
|
|
|
|
suspend fun updateComment(commentId: String, content: String): Comment {
|
|
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
|
|
return api.updateComment(commentId, content)
|
|
}
|
|
|
|
suspend fun deleteComment(commentId: String) {
|
|
if (isOffline) throw OfflineNotAvailableException("Comentarios no disponible sin conexión")
|
|
api.deleteComment(commentId)
|
|
}
|
|
|
|
// ── History ──
|
|
|
|
suspend fun getPageHistory(pageId: String): List<PageHistory> {
|
|
if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión")
|
|
return api.getPageHistory(pageId)
|
|
}
|
|
|
|
suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory {
|
|
if (isOffline) throw OfflineNotAvailableException("Historial no disponible sin conexión")
|
|
return api.getPageHistoryInfo(pageId, historyId)
|
|
}
|
|
|
|
// ── User ──
|
|
|
|
suspend fun getCurrentUser(): User {
|
|
if (isOffline) throw OfflineNotAvailableException("Usuario no disponible sin conexión")
|
|
return api.getCurrentUser()
|
|
}
|
|
|
|
suspend fun updateUser(name: String?): User {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
return api.updateUser(name)
|
|
}
|
|
|
|
// ── Search ──
|
|
|
|
suspend fun searchPages(query: String): List<SearchResult> {
|
|
if (isOffline) throw OfflineNotAvailableException("Búsqueda no disponible sin conexión")
|
|
return api.searchPages(query)
|
|
}
|
|
|
|
// ── Attachments ──
|
|
|
|
suspend fun uploadAttachment(pageId: String, fileUri: Uri, context: Context): Attachment {
|
|
if (isOffline) {
|
|
val fileName = getFileNameFromUri(context, fileUri) ?: "file"
|
|
val mimeType = context.contentResolver.getType(fileUri) ?: "application/octet-stream"
|
|
val pendingDir = File(context.cacheDir, "attachments_pending/$pageId")
|
|
pendingDir.mkdirs()
|
|
val localFile = File(pendingDir, fileName)
|
|
try {
|
|
context.contentResolver.openInputStream(fileUri)?.use { input ->
|
|
localFile.outputStream().use { output ->
|
|
input.copyTo(output)
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
throw IOException("No se pudo guardar el archivo localmente: ${e.message}")
|
|
}
|
|
syncManager.enqueueUploadAttachment(pageId, localFile.absolutePath, fileName, mimeType)
|
|
|
|
return Attachment(
|
|
id = "pending_${System.currentTimeMillis()}",
|
|
fileName = fileName,
|
|
fileSize = localFile.length(),
|
|
mimeType = mimeType,
|
|
url = localFile.toURI().toString(),
|
|
pageId = pageId
|
|
)
|
|
}
|
|
return api.uploadAttachment(pageId, fileUri, context)
|
|
}
|
|
|
|
suspend fun getPageAttachments(pageId: String): List<Attachment> {
|
|
if (isOffline) throw OfflineNotAvailableException("Archivos adjuntos no disponible sin conexión")
|
|
return api.getPageAttachments(pageId)
|
|
}
|
|
|
|
suspend fun getPageContent(pageId: String): PageContent {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
return api.getPageContent(pageId)
|
|
}
|
|
|
|
// ── Trash ──
|
|
|
|
suspend fun getDeletedPages(spaceId: String): List<DeletedPage> {
|
|
if (isOffline) throw OfflineNotAvailableException("Papelera no disponible sin conexión")
|
|
return api.getDeletedPages(spaceId)
|
|
}
|
|
|
|
suspend fun restorePage(pageId: String, spaceId: String? = null) {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
api.restorePage(pageId, spaceId)
|
|
}
|
|
|
|
// ── Helpers ──
|
|
|
|
suspend fun getPageHtml(pageId: String): String {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
return api.getPageHtml(pageId)
|
|
}
|
|
|
|
fun getAttachmentUrl(attachmentType: String, fileName: String): String {
|
|
return api.getAttachmentUrl(attachmentType, fileName)
|
|
}
|
|
|
|
private fun getFileNameFromUri(context: Context, uri: Uri): String? {
|
|
var fileName: String? = null
|
|
val cursor = context.contentResolver.query(uri, null, null, null, null)
|
|
cursor?.use {
|
|
if (it.moveToFirst()) {
|
|
val displayNameIndex = it.getColumnIndex(android.provider.OpenableColumns.DISPLAY_NAME)
|
|
if (displayNameIndex >= 0) {
|
|
fileName = it.getString(displayNameIndex)
|
|
}
|
|
}
|
|
}
|
|
return fileName ?: uri.lastPathSegment
|
|
}
|
|
|
|
// ── Sync All ──
|
|
|
|
suspend fun syncAllPages(onProgress: (SyncProgress) -> Unit): Pair<Int, Int> {
|
|
if (isOffline) throw OfflineNotAvailableException("No disponible sin conexión")
|
|
if (isSyncing) throw IllegalStateException("Sync already in progress")
|
|
isSyncing = true
|
|
var skipped = 0
|
|
var errors = 0
|
|
var totalSynced = 0
|
|
try {
|
|
val spaces = api.getSpaces()
|
|
spaceCache.cacheSpaces(spaces)
|
|
val allRootPages = mutableMapOf<String, List<Page>>()
|
|
|
|
for ((spaceIdx, space) in spaces.withIndex()) {
|
|
try {
|
|
val rootPages = api.getSidebarPages(space.id)
|
|
allRootPages[space.id] = rootPages
|
|
pageCache.cachePagesForSpace(space.id, rootPages)
|
|
|
|
val allPageIds = mutableListOf<Page>()
|
|
allPageIds.addAll(rootPages)
|
|
rootPages.forEach { page ->
|
|
if (page.hasChildren) {
|
|
val children = fetchAllChildren(space.id, page)
|
|
allPageIds.addAll(children)
|
|
}
|
|
}
|
|
|
|
var pageIdx = 0
|
|
for (page in allPageIds) {
|
|
if (!isSyncing) break
|
|
pageIdx++
|
|
onProgress(SyncProgress(
|
|
spaceName = space.name,
|
|
spaceIndex = spaceIdx + 1,
|
|
totalSpaces = spaces.size,
|
|
pageTitle = page.title,
|
|
pageCount = pageIdx,
|
|
totalPages = allPageIds.size,
|
|
skipped = skipped,
|
|
errors = errors
|
|
))
|
|
try {
|
|
val cached = pageCache.getCachedPage(page.id)
|
|
if (cached != null && cached.updatedAt == page.updatedAt) {
|
|
skipped++
|
|
continue
|
|
}
|
|
val fetchedPage = api.getPageInfo(page.id)
|
|
pageCache.cachePage(fetchedPage)
|
|
totalSynced++
|
|
} catch (e: Exception) {
|
|
errors++
|
|
if (BuildConfig.DEBUG) Log.e("CachedApi", "Sync page error: ${e.message}")
|
|
}
|
|
}
|
|
} catch (e: Exception) {
|
|
errors++
|
|
if (BuildConfig.DEBUG) Log.e("CachedApi", "Sync space error: ${e.message}")
|
|
}
|
|
}
|
|
} finally {
|
|
isSyncing = false
|
|
}
|
|
return Pair(totalSynced, errors)
|
|
}
|
|
|
|
private suspend fun fetchAllChildren(spaceId: String, parentPage: Page): List<Page> {
|
|
val children = mutableListOf<Page>()
|
|
try {
|
|
val childPages = api.getSidebarPages(spaceId, parentPage.id)
|
|
children.addAll(childPages)
|
|
childPages.forEach { child ->
|
|
if (child.hasChildren) {
|
|
children.addAll(fetchAllChildren(spaceId, child))
|
|
}
|
|
}
|
|
} catch (_: Exception) { }
|
|
return children
|
|
}
|
|
|
|
fun stopSync() {
|
|
isSyncing = false
|
|
}
|
|
|
|
fun clearAllCachedPages(): Int {
|
|
return pageCache.clearAllCachedPages()
|
|
}
|
|
|
|
fun getCachedPageCount(): Int = pageCache.getCachedPageCount()
|
|
}
|