Files
Docmost-App/app/src/main/java/com/docmost/app/api/CachedApi.kt
Nino 57f8858708 v1.0.6: Add Sync All and Clean Cache features
- 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
2026-07-27 20:26:31 -06:00

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()
}