7 Commits

Author SHA1 Message Date
c1eec3cf18 v1.0.8: Improve offline pages dialog UI - search, dates, cleaner layout 2026-07-27 21:21:03 -06:00
f265498ed3 v1.0.7: Fix false offline modifications causing 'untitled' pages
Root cause chain:
1. JS _pendingSet cleared prematurely in onReady (0ms), letting onChange
   fire during initial content load and set userEdited=true
2. onAutoSave didn't check isEditorReadOnly, firing even for read-only pages
3. Markdown round-trip (extractText → markdownToBlocks → blocksToMarkdown)
   produced different output, triggering false change detection
4. onSaveContent passed null for title when no title changes, which
   CachedApi converted to empty string, causing server to set title to 'untitled'

Fixes:
- JS: Remove premature _pendingSet clearing in onReady; increase timeout to 500ms
- Kotlin: onAutoSave checks isEditorReadOnly (no auto-save in read-only mode)
- Kotlin: Save operations always pass current title (never null) to prevent
  empty title being sent to server
- Kotlin: Add isContentLoading flag to suppress onContentChanged during initial
  content load, preventing false currentPageContent updates
- Added APK versioned naming for Obtanium compatibility
2026-07-27 20:56:59 -06:00
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
922d31b7df v1.0.5: Fix history viewer - ContentConverter JsonElement type bug
- ContentConverter.tipTapToHtml() had same JsonObject.asMap() bug as tiptapToMarkdown
- All as? String casts silently returned null, producing empty HTML
- Now uses Gson TypeToken<Map<String, Any?>> for proper deserialization
- Added contentToHtml() that handles String (HTML), Map (TipTap), and JSON string
- Fixed heading level cast (Gson deserializes numbers as Double, not Int)
- VersionViewerActivity handles all content types correctly
2026-07-27 17:39:48 -06:00
ae57a0bf09 v1.0.4: Fix tiptapToMarkdown JsonElement type casting
- tiptapToMarkdown used JsonObject.asMap() which returns Map<String, JsonElement>
- All 'as? String' casts silently failed (JsonPrimitive != String), returning empty
- Now uses Gson TypeToken<Map<String, Any?>> for proper raw type deserialization
- contentToMarkdown now handles Map types directly without serialize roundtrip
2026-07-27 17:32:47 -06:00
7be826f9ba v1.0.3: Fix TipTap JSON content conversion for editor display
- Replace all TypeToken usage with JsonParser/gson.fromJson (fixes R8/ProGuard crash)
- Add parseJsonArray/parseJsonObject helpers for API response parsing
- Fix contentToMarkdown to properly convert TipTap JSON objects to Markdown
  using tiptapToMarkdown instead of returning raw JSON
- Fix API endpoints to handle unwrapped response formats
- Add ProGuard rules for Gson TypeToken preservation
- Redirect build dir to /tmp for Samba compatibility
2026-07-27 17:25:40 -06:00
bc6ed08648 feat: add release build config, signing support and v1.0.1 2026-07-26 22:02:06 -06:00
19 changed files with 684 additions and 135 deletions

4
.gitignore vendored
View File

@@ -55,3 +55,7 @@ DocmostApp.md
# App build # App build
app/build/ app/build/
# Signing
*.jks
keystore.properties

View File

@@ -1,9 +1,17 @@
import java.util.Properties
plugins { plugins {
id("com.android.application") id("com.android.application")
id("org.jetbrains.kotlin.android") id("org.jetbrains.kotlin.android")
kotlin("kapt") kotlin("kapt")
} }
val keystorePropertiesFile = rootProject.file("keystore.properties")
val keystoreProperties = Properties()
if (keystorePropertiesFile.exists()) {
keystoreProperties.load(keystorePropertiesFile.inputStream())
}
android { android {
namespace = "com.docmost.app" namespace = "com.docmost.app"
compileSdk = 34 compileSdk = 34
@@ -12,14 +20,24 @@ android {
applicationId = "com.docmost.app" applicationId = "com.docmost.app"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 1 versionCode = 9
versionName = "1.0" versionName = "1.0.8"
}
signingConfigs {
create("release") {
storeFile = file(keystoreProperties.getProperty("storeFile", "release.jks"))
storePassword = keystoreProperties.getProperty("storePassword", "")
keyAlias = keystoreProperties.getProperty("keyAlias", "")
keyPassword = keystoreProperties.getProperty("keyPassword", "")
}
} }
buildTypes { buildTypes {
release { release {
isMinifyEnabled = true isMinifyEnabled = true
isShrinkResources = true isShrinkResources = true
signingConfig = signingConfigs.getByName("release")
proguardFiles( proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"), getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro" "proguard-rules.pro"
@@ -38,6 +56,14 @@ android {
buildFeatures { buildFeatures {
viewBinding = true viewBinding = true
buildConfig = true
}
applicationVariants.all {
outputs.all {
val output = this as com.android.build.gradle.internal.api.BaseVariantOutputImpl
output.outputFileName = "Docmost-app-v${versionName}-${buildType.name}.apk"
}
} }
} }

View File

@@ -2,5 +2,14 @@
-keepattributes Signature -keepattributes Signature
-keepattributes *Annotation* -keepattributes *Annotation*
-keep class com.docmost.app.api.** { *; } -keep class com.docmost.app.api.** { *; }
-keep class com.docmost.app.data.** { *; }
-dontwarn okhttp3.** -dontwarn okhttp3.**
-dontwarn okio.** -dontwarn okio.**
# Gson - preserve TypeToken anonymous subclasses and generic signatures
-keep class com.google.gson.** { *; }
-keep class * extends com.google.gson.reflect.TypeToken { *; }
-keep class * extends com.google.gson.TypeAdapter { *; }
-keepattributes Signature
-keepattributes RuntimeVisibleAnnotations
-keep class sun.misc.Unsafe { *; }

View File

@@ -531,12 +531,9 @@
} else { } else {
console.log('AndroidInterface not available yet'); console.log('AndroidInterface not available yet');
} }
// Android controla la carga de contenido via setContent() // _pendingSet is cleared by setContent() after 100ms
// No cargar pendingAndroidContent automáticamente // Do NOT clear it here — doing so prematurely lets onChange
// Clear pending set flag on next tick after initialization // fire during initial content load and sets userEdited=true
setTimeout(function() {
window._pendingSet = false;
}, 0);
// Configurar observer para detectar cambios en bloques vacíos // Configurar observer para detectar cambios en bloques vacíos
setTimeout(function() { setTimeout(function() {
setupBackspaceHandler(); setupBackspaceHandler();
@@ -617,7 +614,7 @@
window.setContentLock = false; window.setContentLock = false;
setTimeout(function() { setTimeout(function() {
window._pendingSet = false; window._pendingSet = false;
}, 100); }, 500);
}; };
window.getContent = function() { window.getContent = function() {

View File

@@ -3,6 +3,7 @@ package com.docmost.app.api
import android.content.Context import android.content.Context
import android.net.Uri import android.net.Uri
import android.util.Log import android.util.Log
import com.docmost.app.BuildConfig
import com.docmost.app.utils.NetworkConnectivityObserver import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.data.PageCacheManager import com.docmost.app.data.PageCacheManager
import com.docmost.app.data.SpaceCacheManager import com.docmost.app.data.SpaceCacheManager
@@ -18,10 +19,23 @@ class CachedApi(
private val networkObserver: NetworkConnectivityObserver private val networkObserver: NetworkConnectivityObserver
) { ) {
var forcedOffline: Boolean = false var forcedOffline: Boolean = false
var isSyncing: Boolean = false
private set
val isOffline: Boolean val isOffline: Boolean
get() = forcedOffline || networkObserver.isOffline() 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 { private fun <T> cacheOrThrow(block: () -> T): T {
return try { return try {
block() block()
@@ -365,4 +379,96 @@ class CachedApi(
} }
return fileName ?: uri.lastPathSegment 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()
} }

View File

@@ -3,7 +3,6 @@ package com.docmost.app.api
import android.util.Log import android.util.Log
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import android.content.Context import android.content.Context
@@ -119,17 +118,55 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getSpaces(): List<Space> = withContext(Dispatchers.IO) { suspend fun getSpaces(): List<Space> = withContext(Dispatchers.IO) {
val json = post("/api/spaces") val json = post("/api/spaces")
val type = object : TypeToken<DocmostResponse<SpacesData>>() {}.type if (BuildConfig.DEBUG) Log.d("DocmostAPI", "Spaces response: $json")
val response = gson.fromJson<DocmostResponse<SpacesData>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
val items = response.data?.items ?: emptyList() if (element.isJsonArray) {
items element.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) {
val items = data.asJsonObject.get("items")
if (items != null && items.isJsonArray) {
items.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else emptyList()
} else if (data != null && data.isJsonArray) {
data.asJsonArray.map { gson.fromJson(it, Space::class.java) }
} else emptyList()
}
} }
suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> = withContext(Dispatchers.IO) { suspend fun getSidebarPages(spaceId: String, pageId: String? = null): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId)) val json = post("/api/pages/sidebar-pages", SidebarPagesRequest(spaceId = spaceId, pageId = pageId))
val type = object : TypeToken<DocmostResponse<PagesData>>() {}.type parseJsonArray<Page>(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<PagesData>>(json, type) }
response.data?.items ?: emptyList()
private fun <T> parseJsonArray(json: String, mapper: (com.google.gson.JsonElement) -> T): List<T> {
val element = com.google.gson.JsonParser.parseString(json)
if (element.isJsonArray) {
return element.asJsonArray.map(mapper)
}
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) {
val items = data.asJsonObject.get("items")
if (items != null && items.isJsonArray) {
return items.asJsonArray.map(mapper)
}
} else if (data != null && data.isJsonArray) {
return data.asJsonArray.map(mapper)
}
return emptyList()
}
private fun <T> parseJsonObject(json: String, mapper: (String) -> T): T? {
val element = com.google.gson.JsonParser.parseString(json)
val objJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
return mapper(objJson)
} }
private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList<Page>) { private suspend fun fetchPageChildren(spaceId: String, parentPage: Page, allPages: MutableList<Page>) {
@@ -156,9 +193,16 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) { suspend fun getPageInfo(pageId: String, format: String = "json"): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId, format)) val json = post("/api/pages/info", PageInfoRequest(pageId, format))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type Log.d("DocmostAPI", "PageInfo response: $json")
val response = gson.fromJson<DocmostResponse<Page>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
response.data ?: throw IOException("Page not found in response") val pageJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
Log.d("DocmostAPI", "PageInfo parsed: $pageJson")
val page = gson.fromJson(pageJson, Page::class.java)
page ?: throw IOException("Page not found in response")
} }
suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) { suspend fun getPageHtml(pageId: String): String = withContext(Dispatchers.IO) {
@@ -181,9 +225,8 @@ class DocmostApi(private val baseUrl: String) {
description = description description = description
) )
) )
val type = object : TypeToken<DocmostResponse<Space>>() {}.type parseJsonObject(json) { gson.fromJson(it, Space::class.java) }
val response = gson.fromJson<DocmostResponse<Space>>(json, type) ?: throw IOException("Space update response missing data")
response.data ?: throw IOException("Space update response missing data")
} }
suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String = suspend fun uploadSpaceIcon(spaceId: String, imageUri: Uri, context: Context): String =
@@ -235,38 +278,32 @@ class DocmostApi(private val baseUrl: String) {
operation = "replace" operation = "replace"
) )
) )
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Update response missing data")
response.data ?: throw IOException("Update response missing data")
} }
suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) { suspend fun getCurrentUser(): User = withContext(Dispatchers.IO) {
val json = post("/api/users/me") val json = post("/api/users/me")
val type = object : TypeToken<DocmostResponse<UserInfoResponse>>() {}.type val user = parseJsonObject(json) { gson.fromJson(it, User::class.java) }
val response = gson.fromJson<DocmostResponse<UserInfoResponse>>(json, type) user ?: throw IOException("User not found in response")
response.data?.user ?: throw IOException("User not found in response")
} }
suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) { suspend fun updateUser(name: String?): User = withContext(Dispatchers.IO) {
val json = post("/api/users/update", UserUpdateRequest(name = name)) val json = post("/api/users/update", UserUpdateRequest(name = name))
val type = object : TypeToken<DocmostResponse<User>>() {}.type parseJsonObject(json) { gson.fromJson(it, User::class.java) }
val response = gson.fromJson<DocmostResponse<User>>(json, type) ?: throw IOException("Update user response missing data")
response.data ?: throw IOException("Update user response missing data")
} }
suspend fun searchPages(query: String): List<SearchResult> = withContext(Dispatchers.IO) { suspend fun searchPages(query: String): List<SearchResult> = withContext(Dispatchers.IO) {
val json = post("/api/search", SearchRequest(query = query)) val json = post("/api/search", SearchRequest(query = query))
val type = object : TypeToken<DocmostResponse<SearchResultsResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, SearchResult::class.java) }
val response = gson.fromJson<DocmostResponse<SearchResultsResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createSpace(name: String, slug: String, description: String? = null): Space = suspend fun createSpace(name: String, slug: String, description: String? = null): Space =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description)) val json = post("/api/spaces/create", CreateSpaceRequest(name = name, slug = slug, description = description))
val type = object : TypeToken<DocmostResponse<Space>>() {}.type parseJsonObject(json) { gson.fromJson(it, Space::class.java) }
val response = gson.fromJson<DocmostResponse<Space>>(json, type) ?: throw IOException("Create space response missing data")
response.data ?: throw IOException("Create space response missing data")
} }
suspend fun removeAvatar() = withContext(Dispatchers.IO) { suspend fun removeAvatar() = withContext(Dispatchers.IO) {
@@ -306,16 +343,13 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getRecentPages(): List<Page> = withContext(Dispatchers.IO) { suspend fun getRecentPages(): List<Page> = withContext(Dispatchers.IO) {
val json = post("/api/pages/recent", RecentPageRequest()) val json = post("/api/pages/recent", RecentPageRequest())
val type = object : TypeToken<DocmostResponse<RecentPagesResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<RecentPagesResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) { suspend fun createPage(title: String, spaceId: String): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId)) val json = post("/api/pages/create", CreatePageRequest(title = title, spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Create page response missing data")
response.data ?: throw IOException("Create page response missing data")
} }
suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) { suspend fun deletePage(pageId: String) = withContext(Dispatchers.IO) {
@@ -328,16 +362,13 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getShares(): List<Share> = withContext(Dispatchers.IO) { suspend fun getShares(): List<Share> = withContext(Dispatchers.IO) {
val json = post("/api/shares/", mapOf("limit" to 50)) val json = post("/api/shares/", mapOf("limit" to 50))
val type = object : TypeToken<DocmostResponse<SharesData>>() {}.type parseJsonArray(json) { gson.fromJson(it, Share::class.java) }
val response = gson.fromJson<DocmostResponse<SharesData>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) { suspend fun createShare(pageId: String): Share = withContext(Dispatchers.IO) {
val json = post("/api/shares/create", CreateShareDto(pageId = pageId)) val json = post("/api/shares/create", CreateShareDto(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Share>>() {}.type parseJsonObject(json) { gson.fromJson(it, Share::class.java) }
val response = gson.fromJson<DocmostResponse<Share>>(json, type) ?: throw IOException("Create share response missing data")
response.data ?: throw IOException("Create share response missing data")
} }
suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) { suspend fun deleteShare(shareId: String) = withContext(Dispatchers.IO) {
@@ -346,23 +377,19 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getComments(pageId: String): List<Comment> = withContext(Dispatchers.IO) { suspend fun getComments(pageId: String): List<Comment> = withContext(Dispatchers.IO) {
val json = post("/api/comments/", mapOf("pageId" to pageId)) val json = post("/api/comments/", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<CommentsData>>() {}.type parseJsonArray(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<CommentsData>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) { suspend fun createComment(pageId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content)) val json = post("/api/comments/create", CreateCommentDto(pageId = pageId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type parseJsonObject(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<Comment>>(json, type) ?: throw IOException("Create comment response missing data")
response.data ?: throw IOException("Create comment response missing data")
} }
suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) { suspend fun updateComment(commentId: String, content: String): Comment = withContext(Dispatchers.IO) {
val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content)) val json = post("/api/comments/update", UpdateCommentDto(commentId = commentId, content = content))
val type = object : TypeToken<DocmostResponse<Comment>>() {}.type parseJsonObject(json) { gson.fromJson(it, Comment::class.java) }
val response = gson.fromJson<DocmostResponse<Comment>>(json, type) ?: throw IOException("Update comment response missing data")
response.data ?: throw IOException("Update comment response missing data")
} }
suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) { suspend fun deleteComment(commentId: String) = withContext(Dispatchers.IO) {
@@ -371,9 +398,8 @@ class DocmostApi(private val baseUrl: String) {
suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) { suspend fun duplicatePage(pageId: String, targetSpaceId: String? = null): Page = withContext(Dispatchers.IO) {
val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId)) val json = post("/api/pages/duplicate", DuplicatePageRequest(pageId = pageId, spaceId = targetSpaceId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type parseJsonObject(json) { gson.fromJson(it, Page::class.java) }
val response = gson.fromJson<DocmostResponse<Page>>(json, type) ?: throw IOException("Duplicate response missing data")
response.data ?: throw IOException("Duplicate response missing data")
} }
suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) { suspend fun movePageToSpace(pageId: String, spaceId: String) = withContext(Dispatchers.IO) {
@@ -391,31 +417,33 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageHistory(pageId: String): List<PageHistory> = withContext(Dispatchers.IO) { suspend fun getPageHistory(pageId: String): List<PageHistory> = withContext(Dispatchers.IO) {
val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId)) val json = post("/api/pages/history", PageHistoryRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<PageHistoryResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, PageHistory::class.java) }
val response = gson.fromJson<DocmostResponse<PageHistoryResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) { suspend fun getPageHistoryInfo(pageId: String, historyId: String): PageHistory = withContext(Dispatchers.IO) {
val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId)) val json = post("/api/pages/history/info", mapOf("pageId" to pageId, "historyId" to historyId))
val type = object : TypeToken<DocmostResponse<PageHistory>>() {}.type parseJsonObject(json) { gson.fromJson(it, PageHistory::class.java) }
val response = gson.fromJson<DocmostResponse<PageHistory>>(json, type) ?: throw IOException("Page history not found in response")
response.data ?: throw IOException("Page history not found in response")
} }
suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) { suspend fun getPageContent(pageId: String): PageContent = withContext(Dispatchers.IO) {
val json = post("/api/pages/info", PageInfoRequest(pageId = pageId)) val json = post("/api/pages/info", PageInfoRequest(pageId = pageId))
val type = object : TypeToken<DocmostResponse<Page>>() {}.type Log.d("DocmostAPI", "PageContent response: $json")
val response = gson.fromJson<DocmostResponse<Page>>(json, type) val element = com.google.gson.JsonParser.parseString(json)
val page = response.data ?: throw IOException("Page not found in response") val pageJson = if (element.isJsonObject) {
val obj = element.asJsonObject
val data = obj.get("data")
if (data != null && data.isJsonObject) data.toString() else json
} else json
Log.d("DocmostAPI", "PageContent parsed: $pageJson")
val page = gson.fromJson(pageJson, Page::class.java)
page ?: throw IOException("Page not found in response")
PageContent(source = page.source ?: "") PageContent(source = page.source ?: "")
} }
suspend fun getDeletedPages(spaceId: String): List<DeletedPage> = withContext(Dispatchers.IO) { suspend fun getDeletedPages(spaceId: String): List<DeletedPage> = withContext(Dispatchers.IO) {
val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId)) val json = post("/api/pages/trash", DeletedPagesRequest(spaceId = spaceId))
val type = object : TypeToken<DocmostResponse<DeletedPagesResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, DeletedPage::class.java) }
val response = gson.fromJson<DocmostResponse<DeletedPagesResponse>>(json, type)
response.data?.items ?: emptyList()
} }
suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) { suspend fun restorePage(pageId: String, spaceId: String? = null) = withContext(Dispatchers.IO) {
@@ -467,9 +495,7 @@ class DocmostApi(private val baseUrl: String) {
suspend fun getPageAttachments(pageId: String): List<Attachment> = withContext(Dispatchers.IO) { suspend fun getPageAttachments(pageId: String): List<Attachment> = withContext(Dispatchers.IO) {
val json = post("/api/attachments/page", mapOf("pageId" to pageId)) val json = post("/api/attachments/page", mapOf("pageId" to pageId))
val type = object : TypeToken<DocmostResponse<AttachmentsResponse>>() {}.type parseJsonArray(json) { gson.fromJson(it, Attachment::class.java) }
val response = gson.fromJson<DocmostResponse<AttachmentsResponse>>(json, type)
response.data?.items ?: emptyList()
} }
private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? { private fun getFileNameFromUri(context: android.content.Context, uri: android.net.Uri): String? {

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.docmost.app.api.Page import com.docmost.app.api.Page
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.JsonParser
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -123,8 +123,8 @@ class PageCacheManager(private val context: Context) {
fun getCachedPages(): List<CachedPageMeta> { fun getCachedPages(): List<CachedPageMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedPageMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }
@@ -153,8 +153,8 @@ class PageCacheManager(private val context: Context) {
return try { return try {
val file = File(spacePagesDir, "$spaceId.json") val file = File(spacePagesDir, "$spaceId.json")
if (!file.exists()) return null if (!file.exists()) return null
val type = object : TypeToken<List<Page>>() {}.type val array = JsonParser.parseString(file.readText()).asJsonArray
gson.fromJson(file.readText(), type) array.map { gson.fromJson(it, Page::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
@@ -175,11 +175,27 @@ class PageCacheManager(private val context: Context) {
fun getCachedPageCount(): Int = getIndex().size fun getCachedPageCount(): Int = getIndex().size
fun clearAllCachedPages(): Int {
var count = 0
try {
val index = getIndex()
count = index.size
cacheDir.listFiles()?.forEach { file ->
if (file.isFile && file.name != "index.json") {
file.delete()
}
}
spacePagesDir.listFiles()?.forEach { it.delete() }
indexFile.writeText("[]")
} catch (_: Exception) { }
return count
}
private fun getIndex(): List<CachedPageMeta> { private fun getIndex(): List<CachedPageMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedPageMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedPageMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -5,6 +5,7 @@ import android.content.SharedPreferences
import androidx.security.crypto.EncryptedSharedPreferences import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKeys import androidx.security.crypto.MasterKeys
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.JsonParser
data class Credentials( data class Credentials(
@@ -110,8 +111,8 @@ class SessionManager(private val context: Context) {
null null
} ?: return emptyList() } ?: return emptyList()
return try { return try {
val type = com.google.gson.reflect.TypeToken.getParameterized(List::class.java, Credentials::class.java).type val array = JsonParser.parseString(json).asJsonArray
gson.fromJson(json, type) array.map { gson.fromJson(it, Credentials::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -4,7 +4,7 @@ import android.content.Context
import com.docmost.app.api.Space import com.docmost.app.api.Space
import com.docmost.app.BuildConfig import com.docmost.app.BuildConfig
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken import com.google.gson.JsonParser
import java.io.File import java.io.File
import java.security.MessageDigest import java.security.MessageDigest
@@ -119,8 +119,8 @@ class SpaceCacheManager(private val context: Context) {
fun getIndex(): List<CachedSpaceMeta> { fun getIndex(): List<CachedSpaceMeta> {
return try { return try {
if (!indexFile.exists()) return emptyList() if (!indexFile.exists()) return emptyList()
val type = object : TypeToken<List<CachedSpaceMeta>>() {}.type val array = JsonParser.parseString(indexFile.readText()).asJsonArray
gson.fromJson(indexFile.readText(), type) ?: emptyList() array.map { gson.fromJson(it, CachedSpaceMeta::class.java) }
} catch (e: Exception) { } catch (e: Exception) {
emptyList() emptyList()
} }

View File

@@ -6,7 +6,6 @@ import androidx.room.PrimaryKey
import androidx.room.TypeConverter import androidx.room.TypeConverter
import androidx.room.TypeConverters import androidx.room.TypeConverters
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
enum class OperationType { enum class OperationType {
CREATE_SPACE, CREATE_SPACE,

View File

@@ -59,7 +59,6 @@ import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.utils.NetworkStatus import com.docmost.app.utils.NetworkStatus
import com.docmost.app.databinding.ActivityPageEditorBinding import com.docmost.app.databinding.ActivityPageEditorBinding
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -284,6 +283,7 @@ class PageEditorActivity : AppCompatActivity() {
} }
private var isEditorRendered = false private var isEditorRendered = false
private var isContentLoading = false
private var pendingContent: String? = null private var pendingContent: String? = null
private var isTitleVisible = true private var isTitleVisible = true
private var isAnimating = false private var isAnimating = false
@@ -628,6 +628,7 @@ class PageEditorActivity : AppCompatActivity() {
@JavascriptInterface @JavascriptInterface
fun onContentChanged(markdown: String) { fun onContentChanged(markdown: String) {
if (isContentLoading) return
currentPageContent = markdown currentPageContent = markdown
} }
@@ -641,12 +642,15 @@ class PageEditorActivity : AppCompatActivity() {
loadEditorContentInternal(content) loadEditorContentInternal(content)
pendingContent = null pendingContent = null
} }
Handler(Looper.getMainLooper()).postDelayed({
isContentLoading = false
}, 600)
} }
} }
@JavascriptInterface @JavascriptInterface
fun onAutoSave(markdown: String) { fun onAutoSave(markdown: String) {
if (!canEdit) return if (!canEdit || isEditorReadOnly) return
runOnUiThread { runOnUiThread {
val title = binding.etTitle.text.toString().trim() val title = binding.etTitle.text.toString().trim()
@@ -699,14 +703,14 @@ class PageEditorActivity : AppCompatActivity() {
try { try {
cachedApi.updatePage( cachedApi.updatePage(
currentPageId, markdown, currentPageId, markdown,
if (hasTitleChanges) title else null, title,
if (hasEmojiChanges) currentEmoji else null if (hasEmojiChanges) currentEmoji else null
) )
val cachedPage = pageCacheManager.getCachedPage(currentPageId) val cachedPage = pageCacheManager.getCachedPage(currentPageId)
if (cachedPage != null) { if (cachedPage != null) {
pageCacheManager.cachePage(cachedPage.copy( pageCacheManager.cachePage(cachedPage.copy(
title = if (hasTitleChanges) title else cachedPage.title, title = title,
icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon, icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon,
content = markdown content = markdown
)) ))
@@ -741,7 +745,7 @@ class PageEditorActivity : AppCompatActivity() {
try { try {
cachedApi.updatePage( cachedApi.updatePage(
currentPageId, null, currentPageId, null,
if (hasTitleChanges) title else null, title,
if (hasEmojiChanges) currentEmoji else null if (hasEmojiChanges) currentEmoji else null
) )
} catch (e: Exception) { } catch (e: Exception) {
@@ -769,7 +773,7 @@ class PageEditorActivity : AppCompatActivity() {
cachedApi.updatePage( cachedApi.updatePage(
pageId!!, pageId!!,
if (hasBodyChanges) markdown else null, if (hasBodyChanges) markdown else null,
if (hasTitleChanges) title else null, title,
if (pageEmoji != originalEmoji) pageEmoji else null if (pageEmoji != originalEmoji) pageEmoji else null
) )
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {
@@ -1005,6 +1009,7 @@ class PageEditorActivity : AppCompatActivity() {
private fun loadEditorContent(markdown: String) { private fun loadEditorContent(markdown: String) {
if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars") if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars")
isContentLoading = true
if (isEditorRendered) { if (isEditorRendered) {
loadEditorContentInternal(markdown) loadEditorContentInternal(markdown)
} else { } else {
@@ -1878,8 +1883,8 @@ class PageEditorActivity : AppCompatActivity() {
private fun tiptapToMarkdown(tiptapJson: String): String { private fun tiptapToMarkdown(tiptapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val type = object : com.google.gson.reflect.TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tiptapJson, mapType) val doc: Map<String, Any?> = gson.fromJson(tiptapJson, type)
extractText(doc) extractText(doc)
} catch (e: Exception) { } catch (e: Exception) {
tiptapJson tiptapJson
@@ -2090,11 +2095,13 @@ class PageEditorActivity : AppCompatActivity() {
canEdit = page.permissions?.canEdit ?: true canEdit = page.permissions?.canEdit ?: true
currentPage = page currentPage = page
android.util.Log.d("PageEditor", "renderPageContent: page.content type=${page.content?.let { it::class.java.simpleName } ?: "null"}")
if (!canEdit) { if (!canEdit) {
loadReadOnlyPreview(page) loadReadOnlyPreview(page)
} else { } else {
val markdown = contentToMarkdown(page.content) val markdown = contentToMarkdown(page.content)
if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "Markdown content length: ${markdown.length}") android.util.Log.d("PageEditor", "Markdown content length: ${markdown.length}")
currentPageContent = markdown currentPageContent = markdown
originalContent = markdown originalContent = markdown
binding.etTitle.setText(page.title) binding.etTitle.setText(page.title)
@@ -2484,13 +2491,66 @@ class PageEditorActivity : AppCompatActivity() {
private fun contentToMarkdown(content: Any?): String { private fun contentToMarkdown(content: Any?): String {
if (content == null) return "" if (content == null) return ""
if (content is String) return content if (content is String) {
return if (content.trimStart().startsWith("<")) {
htmlToMarkdown(content)
} else {
content
}
}
if (content is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
return try {
extractText(content as Map<String, Any?>)
} catch (e: Exception) {
android.util.Log.e("PageEditor", "contentToMarkdown map error: ${e.message}")
""
}
}
return try { return try {
val json = gson.toJson(content) val json = gson.toJson(content)
if (json.isNotEmpty() && json != "null") {
tiptapToMarkdown(json) tiptapToMarkdown(json)
} catch (e: Exception) { } else {
content.toString() ""
} }
} catch (e: Exception) {
android.util.Log.e("PageEditor", "contentToMarkdown error: ${e.message}")
""
}
}
private fun htmlToMarkdown(html: String): String {
return html
.replace(Regex("<h1[^>]*>(.*?)</h1>", RegexOption.DOT_MATCHES_ALL)) { "# ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h2[^>]*>(.*?)</h2>", RegexOption.DOT_MATCHES_ALL)) { "## ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h3[^>]*>(.*?)</h3>", RegexOption.DOT_MATCHES_ALL)) { "### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h4[^>]*>(.*?)</h4>", RegexOption.DOT_MATCHES_ALL)) { "#### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h5[^>]*>(.*?)</h5>", RegexOption.DOT_MATCHES_ALL)) { "##### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<h6[^>]*>(.*?)</h6>", RegexOption.DOT_MATCHES_ALL)) { "###### ${it.groupValues[1].trim()}\n\n" }
.replace(Regex("<strong[^>]*>(.*?)</strong>", RegexOption.DOT_MATCHES_ALL)) { "**${it.groupValues[1]}**" }
.replace(Regex("<b[^>]*>(.*?)</b>", RegexOption.DOT_MATCHES_ALL)) { "**${it.groupValues[1]}**" }
.replace(Regex("<em[^>]*>(.*?)</em>", RegexOption.DOT_MATCHES_ALL)) { "*${it.groupValues[1]}*" }
.replace(Regex("<i[^>]*>(.*?)</i>", RegexOption.DOT_MATCHES_ALL)) { "*${it.groupValues[1]}*" }
.replace(Regex("<del[^>]*>(.*?)</del>", RegexOption.DOT_MATCHES_ALL)) { "~~${it.groupValues[1]}~~" }
.replace(Regex("<code[^>]*>(.*?)</code>", RegexOption.DOT_MATCHES_ALL)) { "`${it.groupValues[1]}`" }
.replace(Regex("<a[^>]*href=\"([^\"]+)\"[^>]*>(.*?)</a>", RegexOption.DOT_MATCHES_ALL)) { "[${it.groupValues[2]}](${it.groupValues[1]})" }
.replace(Regex("<img[^>]*src=\"([^\"]+)\"[^>]*alt=\"([^\"]*)\"[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "![${it.groupValues[2]}](${it.groupValues[1]})" }
.replace(Regex("<img[^>]*alt=\"([^\"]*)\"[^>]*src=\"([^\"]+)\"[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "![${it.groupValues[1]}](${it.groupValues[2]})" }
.replace(Regex("<li[^>]*>(.*?)</li>", RegexOption.DOT_MATCHES_ALL)) { "- ${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n" }
.replace(Regex("<blockquote[^>]*>(.*?)</blockquote>", RegexOption.DOT_MATCHES_ALL)) { "> ${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n\n" }
.replace(Regex("<pre[^>]*><code[^>]*>(.*?)</code></pre>", RegexOption.DOT_MATCHES_ALL)) { "```\n${it.groupValues[1].replace(Regex("<[^>]*>"), "")}\n```\n\n" }
.replace(Regex("<p[^>]*>(.*?)</p>", RegexOption.DOT_MATCHES_ALL)) { "${it.groupValues[1].replace(Regex("<[^>]*>"), "").trim()}\n\n" }
.replace(Regex("<br[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "\n" }
.replace(Regex("<hr[^>]*/?>", RegexOption.DOT_MATCHES_ALL)) { "---\n\n" }
.replace(Regex("<[^>]*>"), "")
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&nbsp;", " ")
.replace(Regex("\n{3,}"), "\n\n")
.trim()
} }
private fun markdownToTipTap(markdown: String): String { private fun markdownToTipTap(markdown: String): String {

View File

@@ -54,7 +54,6 @@ import com.docmost.app.utils.NetworkConnectivityObserver
import com.docmost.app.utils.NetworkStatus import com.docmost.app.utils.NetworkStatus
import com.docmost.app.databinding.ActivitySpacesBinding import com.docmost.app.databinding.ActivitySpacesBinding
import com.google.gson.Gson import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import com.google.android.material.bottomsheet.BottomSheetDialog import com.google.android.material.bottomsheet.BottomSheetDialog
import com.google.android.material.dialog.MaterialAlertDialogBuilder import com.google.android.material.dialog.MaterialAlertDialogBuilder
import com.google.android.material.tabs.TabLayout import com.google.android.material.tabs.TabLayout
@@ -286,6 +285,14 @@ class SpacesActivity : AppCompatActivity() {
showCachedPagesDialog() showCachedPagesDialog()
} }
binding.btnSyncAll.setOnClickListener {
startSyncAll()
}
binding.btnCleanCache.setOnClickListener {
showCleanCacheDialog()
}
binding.btnDeletedPages.setOnClickListener { binding.btnDeletedPages.setOnClickListener {
showDeletedPagesDialog() showDeletedPagesDialog()
} }
@@ -909,8 +916,8 @@ class SpacesActivity : AppCompatActivity() {
private fun loadRecentCache(): List<Page>? { private fun loadRecentCache(): List<Page>? {
try { try {
val json = prefs.getString("recent_cache", null) ?: return null val json = prefs.getString("recent_cache", null) ?: return null
val type = object : TypeToken<List<Page>>() {}.type val array = com.google.gson.JsonParser.parseString(json).asJsonArray
return gson.fromJson(json, type) return array.map { gson.fromJson(it, Page::class.java) }
} catch (_: Exception) { return null } } catch (_: Exception) { return null }
} }
@@ -1652,6 +1659,79 @@ class SpacesActivity : AppCompatActivity() {
Toast.makeText(this, R.string.link_copied, Toast.LENGTH_SHORT).show() Toast.makeText(this, R.string.link_copied, Toast.LENGTH_SHORT).show()
} }
private var syncDialog: android.app.AlertDialog? = null
private fun startSyncAll() {
if (cachedApi.isOffline) {
Toast.makeText(this, R.string.offline_not_available, Toast.LENGTH_SHORT).show()
return
}
if (cachedApi.isSyncing) {
Toast.makeText(this, R.string.syncing, Toast.LENGTH_SHORT).show()
return
}
val progressDialog = android.app.AlertDialog.Builder(this)
.setTitle(R.string.syncing)
.setMessage(getString(R.string.sync_progress, 0, 0, 0, 0))
.setCancelable(true)
.setOnCancelListener {
cachedApi.stopSync()
}
.create()
progressDialog.show()
syncDialog = progressDialog
CoroutineScope(Dispatchers.IO).launch {
try {
val (synced, errors) = cachedApi.syncAllPages { progress ->
runOnUiThread {
if (progressDialog.isShowing) {
progressDialog.setMessage(getString(R.string.sync_progress,
progress.spaceIndex, progress.totalSpaces,
progress.pageCount, progress.totalPages))
}
}
}
withContext(Dispatchers.Main) {
if (progressDialog.isShowing) progressDialog.dismiss()
Toast.makeText(this@SpacesActivity,
getString(R.string.sync_complete, synced, 0, errors),
Toast.LENGTH_LONG).show()
loadSpaces()
}
} catch (e: Exception) {
withContext(Dispatchers.Main) {
if (progressDialog.isShowing) progressDialog.dismiss()
if (e.message?.contains("cancelled", true) == true || e is kotlinx.coroutines.CancellationException) {
Toast.makeText(this@SpacesActivity, R.string.sync_cancelled, Toast.LENGTH_SHORT).show()
} else {
Toast.makeText(this@SpacesActivity, e.message ?: "Error", Toast.LENGTH_LONG).show()
}
}
}
}
}
private fun showCleanCacheDialog() {
val count = cachedApi.getCachedPageCount()
if (count == 0) {
Toast.makeText(this, R.string.no_cached_pages, Toast.LENGTH_SHORT).show()
return
}
com.google.android.material.dialog.MaterialAlertDialogBuilder(this)
.setTitle(R.string.clean_cache)
.setMessage(getString(R.string.clean_confirm) + "\n\n" + getString(R.string.cached_count, count))
.setPositiveButton(R.string.clean_cache) { _, _ ->
val removed = cachedApi.clearAllCachedPages()
Toast.makeText(this, getString(R.string.cache_cleaned, removed), Toast.LENGTH_SHORT).show()
loadSpaces()
}
.setNegativeButton(android.R.string.cancel, null)
.show()
}
private fun showCachedPagesDialog() { private fun showCachedPagesDialog() {
val cachedPages = pageCacheManager.getCachedPages() val cachedPages = pageCacheManager.getCachedPages()
if (cachedPages.isEmpty()) { if (cachedPages.isEmpty()) {
@@ -1659,39 +1739,83 @@ class SpacesActivity : AppCompatActivity() {
return return
} }
val view = layoutInflater.inflate(R.layout.dialog_search, null) val view = layoutInflater.inflate(R.layout.dialog_cached_pages, null)
val tvTitle = view.findViewById<TextView>(R.id.tvDialogTitle)
val tvSubtitle = view.findViewById<TextView>(R.id.tvDialogSubtitle)
val etSearch = view.findViewById<android.widget.EditText>(R.id.etSearch) val etSearch = view.findViewById<android.widget.EditText>(R.id.etSearch)
val rvResults = view.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.rvSearchResults) val rvResults = view.findViewById<RecyclerView>(R.id.rvCachedPages)
val tvEmpty = view.findViewById<TextView>(R.id.tvSearchEmpty) val tvEmpty = view.findViewById<TextView>(R.id.tvEmpty)
etSearch.visibility = View.GONE
tvEmpty.visibility = View.GONE val allPages = cachedPages.sortedByDescending { it.updatedAt }.toMutableList()
val filteredPages = mutableListOf<com.docmost.app.data.CachedPageMeta>()
tvTitle.text = getString(R.string.cached_pages)
tvSubtitle.text = getString(R.string.cached_count, allPages.size)
tvEmpty.text = getString(R.string.no_cached_pages_found)
rvResults.layoutManager = LinearLayoutManager(this) rvResults.layoutManager = LinearLayoutManager(this)
val results = cachedPages.toMutableList()
val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter<androidx.recyclerview.widget.RecyclerView.ViewHolder>() { val dateFormat = java.text.SimpleDateFormat("dd MMM yyyy", java.util.Locale.getDefault())
override fun getItemCount() = results.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder { fun formatDate(millis: Long): String {
val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false) return try {
return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {} dateFormat.format(java.util.Date(millis))
} catch (_: Exception) {
""
} }
override fun onBindViewHolder(holder: androidx.recyclerview.widget.RecyclerView.ViewHolder, position: Int) { }
val r = results[position]
val text1 = holder.itemView.findViewById<android.widget.TextView>(android.R.id.text1) fun updateList(pages: List<com.docmost.app.data.CachedPageMeta>) {
val text2 = holder.itemView.findViewById<android.widget.TextView>(android.R.id.text2) filteredPages.clear()
text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}" filteredPages.addAll(pages)
text2.text = r.spaceId ?: getString(R.string.offline_mode) rvResults.adapter?.notifyDataSetChanged()
tvEmpty.visibility = if (filteredPages.isEmpty()) View.VISIBLE else View.GONE
rvResults.visibility = if (filteredPages.isEmpty()) View.GONE else View.VISIBLE
}
val adapter = object : RecyclerView.Adapter<RecyclerView.ViewHolder>() {
override fun getItemCount() = filteredPages.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): RecyclerView.ViewHolder {
val v = layoutInflater.inflate(R.layout.item_cached_page, parent, false)
return object : RecyclerView.ViewHolder(v) {}
}
override fun onBindViewHolder(holder: RecyclerView.ViewHolder, position: Int) {
val r = filteredPages[position]
val tvIcon = holder.itemView.findViewById<TextView>(R.id.tvPageIcon)
val tvItemTitle = holder.itemView.findViewById<TextView>(R.id.tvPageTitle)
val tvDate = holder.itemView.findViewById<TextView>(R.id.tvPageDate)
tvIcon.text = if (r.icon.isNullOrBlank()) "📄" else r.icon
tvItemTitle.text = r.title
tvDate.text = formatDate(r.updatedAt)
holder.itemView.setOnClickListener { holder.itemView.setOnClickListener {
openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId)) openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId))
} }
} }
} }
rvResults.adapter = adapter rvResults.adapter = adapter
updateList(allPages)
val maxHeight = (resources.displayMetrics.heightPixels * 0.6).toInt() val maxHeight = (resources.displayMetrics.heightPixels * 0.7).toInt()
rvResults.layoutParams.height = maxHeight rvResults.layoutParams.height = maxHeight
val dialog = BottomSheetDialog(this) etSearch.addTextChangedListener(object : TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {}
override fun afterTextChanged(s: Editable?) {
val query = s?.toString()?.trim() ?: ""
if (query.isEmpty()) {
updateList(allPages)
return
}
val lowerQuery = query.lowercase()
val filtered = allPages.filter { it.title.lowercase().contains(lowerQuery) }
updateList(filtered)
}
})
val dialog = com.google.android.material.bottomsheet.BottomSheetDialog(this)
dialog.setContentView(view) dialog.setContentView(view)
dialog.show() dialog.show()
} }

View File

@@ -109,13 +109,26 @@ class VersionViewerActivity : AppCompatActivity() {
CoroutineScope(Dispatchers.IO).launch { CoroutineScope(Dispatchers.IO).launch {
try { try {
val history = api.getPageHistoryInfo(pageId, historyId) val history = api.getPageHistoryInfo(pageId, historyId)
val htmlContent = if (history.content != null) { val htmlContent: String
if (history.content != null) {
val content = history.content
if (content is String && content.trimStart().startsWith("<")) {
htmlContent = content
originalTipTapJson = ""
} else if (content is Map<*, *>) {
val gson = Gson() val gson = Gson()
val json = gson.toJson(history.content) originalTipTapJson = gson.toJson(content)
originalTipTapJson = json htmlContent = ContentConverter.contentToHtml(content)
ContentConverter.tipTapToHtml(json) } else if (content is String) {
originalTipTapJson = content
htmlContent = ContentConverter.tipTapToHtml(content)
} else { } else {
"<p>Sin contenido</p>" htmlContent = "<p>Sin contenido</p>"
originalTipTapJson = ""
}
} else {
htmlContent = "<p>Sin contenido</p>"
originalTipTapJson = ""
} }
withContext(Dispatchers.Main) { withContext(Dispatchers.Main) {

View File

@@ -8,8 +8,8 @@ object ContentConverter {
fun tipTapJsonToHtml(tipTapJson: String): String { fun tipTapJsonToHtml(tipTapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val type = object : TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType) val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
nodeToHtml(doc) nodeToHtml(doc)
} catch (e: Exception) { } catch (e: Exception) {
tipTapJson tipTapJson
@@ -18,14 +18,34 @@ object ContentConverter {
fun tipTapToHtml(tipTapJson: String): String { fun tipTapToHtml(tipTapJson: String): String {
return try { return try {
val mapType = object : TypeToken<Map<String, Any?>>() {}.type val type = object : TypeToken<Map<String, Any?>>() {}.type
val doc = gson.fromJson<Map<String, Any?>>(tipTapJson, mapType) val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
nodeToHtml(doc) nodeToHtml(doc)
} catch (e: Exception) { } catch (e: Exception) {
tipTapJson tipTapJson
} }
} }
fun contentToHtml(content: Any?): String {
if (content == null) return "<p>Sin contenido</p>"
if (content is String) {
return if (content.trimStart().startsWith("<")) {
content
} else {
tipTapToHtml(content)
}
}
if (content is Map<*, *>) {
@Suppress("UNCHECKED_CAST")
return try {
nodeToHtml(content as Map<String, Any?>)
} catch (e: Exception) {
"<p>Error converting content</p>"
}
}
return "<p>Sin contenido</p>"
}
private fun nodeToHtml(node: Map<String, Any?>): String { private fun nodeToHtml(node: Map<String, Any?>): String {
val type = node["type"] as? String val type = node["type"] as? String
@@ -74,7 +94,13 @@ object ContentConverter {
sb.append("</p>") sb.append("</p>")
} }
"heading" -> { "heading" -> {
val level = (node["attrs"] as? Map<*, *>)?.get("level") as? Int ?: 1 val levelRaw = (node["attrs"] as? Map<*, *>)?.get("level")
val level = when (levelRaw) {
is Int -> levelRaw
is Double -> levelRaw.toInt()
is Number -> levelRaw.toInt()
else -> 1
}
sb.append("<h$level>") sb.append("<h$level>")
content?.forEach { child -> content?.forEach { child ->
if (child is Map<*, *>) { if (child is Map<*, *>) {

View File

@@ -471,13 +471,31 @@
android:text="@string/force_offline" android:text="@string/force_offline"
android:layout_marginBottom="12dp" /> android:layout_marginBottom="12dp" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnSyncAll"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/sync_all"
android:icon="@android:drawable/ic_menu_save"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton <com.google.android.material.button.MaterialButton
android:id="@+id/btnCachedPages" android:id="@+id/btnCachedPages"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="48dp" android:layout_height="48dp"
android:text="@string/cached_pages" android:text="@string/cached_pages"
android:layout_marginBottom="8dp"
style="@style/Widget.Material3.Button.TonalButton" /> style="@style/Widget.Material3.Button.TonalButton" />
<com.google.android.material.button.MaterialButton
android:id="@+id/btnCleanCache"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/clean_cache"
android:icon="@drawable/ic_delete"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout> </LinearLayout>
</com.google.android.material.card.MaterialCardView> </com.google.android.material.card.MaterialCardView>

View File

@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:id="@+id/tvDialogTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="18sp"
android:textStyle="bold"
android:textColor="?attr/colorOnSurface"
android:layout_marginBottom="4dp" />
<TextView
android:id="@+id/tvDialogSubtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="13sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginBottom="12dp" />
<EditText
android:id="@+id/etSearch"
android:layout_width="match_parent"
android:layout_height="48dp"
android:hint="@string/search_cached_pages"
android:inputType="text"
android:textSize="15sp"
android:background="@drawable/bg_search"
android:drawableStart="@drawable/ic_search"
android:drawablePadding="10dp"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:maxLines="1"
android:layout_marginBottom="8dp" />
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvCachedPages"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:minHeight="48dp"
android:clipToPadding="false" />
<TextView
android:id="@+id/tvEmpty"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textSize="14sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:padding="32dp"
android:visibility="gone" />
</LinearLayout>

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:background="?attr/selectableItemBackground"
android:gravity="center_vertical">
<TextView
android:id="@+id/tvPageIcon"
android:layout_width="40dp"
android:layout_height="40dp"
android:gravity="center"
android:textSize="22sp"
android:text="📄" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical"
android:layout_marginStart="12dp">
<TextView
android:id="@+id/tvPageTitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?attr/colorOnSurface"
android:textStyle="bold"
android:maxLines="1"
android:ellipsize="end" />
<TextView
android:id="@+id/tvPageDate"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="12sp"
android:textColor="?attr/colorOnSurfaceVariant"
android:layout_marginTop="2dp" />
</LinearLayout>
</LinearLayout>

View File

@@ -117,4 +117,17 @@
<string name="deleted_pages">Páginas eliminadas</string> <string name="deleted_pages">Páginas eliminadas</string>
<string name="deleted_pages_description">Ver y restaurar páginas eliminadas recientemente</string> <string name="deleted_pages_description">Ver y restaurar páginas eliminadas recientemente</string>
<string name="deleted_pages_button">🗑️ Páginas eliminadas</string> <string name="deleted_pages_button">🗑️ Páginas eliminadas</string>
<string name="sync_all">Sincronizar todo</string>
<string name="sync_all_description">Descargar todas las páginas para uso sin conexión</string>
<string name="clean_cache">Limpiar caché</string>
<string name="clean_cache_description">Eliminar todas las páginas descargadas</string>
<string name="syncing">Sincronizando…</string>
<string name="sync_progress">Espacio %1$d/%2$d · Página %3$d/%4$d</string>
<string name="sync_complete">Sincronización completa: %1$d descargadas, %2$d actualizadas, %3$d errores</string>
<string name="sync_cancelled">Sincronización cancelada</string>
<string name="clean_confirm">¿Eliminar todas las páginas descargadas? Las borradores locales se conservarán.</string>
<string name="cache_cleaned">Caché limpiada: %d páginas eliminadas</string>
<string name="cached_count">Páginas en caché: %d</string>
<string name="search_cached_pages">Buscar páginas guardadas…</string>
<string name="no_cached_pages_found">No se encontraron páginas</string>
</resources> </resources>

View File

@@ -14,3 +14,12 @@ dependencyResolutionManagement {
rootProject.name = "Docmost" rootProject.name = "Docmost"
include(":app") include(":app")
val localBuildDir = "/tmp/android-build/Docmost"
gradle.projectsLoaded {
rootProject.layout.buildDirectory.set(file(localBuildDir))
rootProject.subprojects.forEach { project ->
project.layout.buildDirectory.set(file("$localBuildDir/${project.name}"))
}
}