Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b640f1f063 | |||
| c1eec3cf18 | |||
| f265498ed3 | |||
| 57f8858708 | |||
| 922d31b7df | |||
| ae57a0bf09 |
@@ -20,8 +20,8 @@ android {
|
|||||||
applicationId = "com.docmost.app"
|
applicationId = "com.docmost.app"
|
||||||
minSdk = 26
|
minSdk = 26
|
||||||
targetSdk = 34
|
targetSdk = 34
|
||||||
versionCode = 4
|
versionCode = 10
|
||||||
versionName = "1.0.3"
|
versionName = "1.0.9"
|
||||||
}
|
}
|
||||||
|
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
@@ -58,6 +58,13 @@ android {
|
|||||||
viewBinding = true
|
viewBinding = true
|
||||||
buildConfig = 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"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,6 +175,22 @@ 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()
|
||||||
|
|||||||
@@ -283,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
|
||||||
@@ -627,6 +628,7 @@ class PageEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
@JavascriptInterface
|
@JavascriptInterface
|
||||||
fun onContentChanged(markdown: String) {
|
fun onContentChanged(markdown: String) {
|
||||||
|
if (isContentLoading) return
|
||||||
currentPageContent = markdown
|
currentPageContent = markdown
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -640,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()
|
||||||
@@ -698,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
|
||||||
))
|
))
|
||||||
@@ -740,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) {
|
||||||
@@ -768,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) {
|
||||||
@@ -1004,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 {
|
||||||
@@ -1877,9 +1883,9 @@ class PageEditorActivity : AppCompatActivity() {
|
|||||||
|
|
||||||
private fun tiptapToMarkdown(tiptapJson: String): String {
|
private fun tiptapToMarkdown(tiptapJson: String): String {
|
||||||
return try {
|
return try {
|
||||||
val doc = gson.fromJson(tiptapJson, com.google.gson.JsonObject::class.java).asMap()
|
val type = object : com.google.gson.reflect.TypeToken<Map<String, Any?>>() {}.type
|
||||||
@Suppress("UNCHECKED_CAST")
|
val doc: Map<String, Any?> = gson.fromJson(tiptapJson, type)
|
||||||
extractText(doc as Map<String, Any?>)
|
extractText(doc)
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
tiptapJson
|
tiptapJson
|
||||||
}
|
}
|
||||||
@@ -2194,6 +2200,10 @@ class PageEditorActivity : AppCompatActivity() {
|
|||||||
a {
|
a {
|
||||||
color: #64b5f6 !important;
|
color: #64b5f6 !important;
|
||||||
}
|
}
|
||||||
|
ul[data-type="taskList"] li > label > input[type="checkbox"] {
|
||||||
|
border-color: #666 !important;
|
||||||
|
accent-color: #369FFF;
|
||||||
|
}
|
||||||
""" else ""
|
""" else ""
|
||||||
|
|
||||||
return """
|
return """
|
||||||
@@ -2229,6 +2239,11 @@ class PageEditorActivity : AppCompatActivity() {
|
|||||||
th, td { border: 1px solid #ddd; padding: 8px; white-space: nowrap; }
|
th, td { border: 1px solid #ddd; padding: 8px; white-space: nowrap; }
|
||||||
th { background: #f4f4f4; }
|
th { background: #f4f4f4; }
|
||||||
a { color: #0066cc; word-wrap: break-word; overflow-wrap: break-word; }
|
a { color: #0066cc; word-wrap: break-word; overflow-wrap: break-word; }
|
||||||
|
ul[data-type="taskList"] { list-style: none; padding-left: 0; margin: 8px 0; }
|
||||||
|
ul[data-type="taskList"] li { display: flex; align-items: flex-start; margin: 4px 0; }
|
||||||
|
ul[data-type="taskList"] li > label { flex-shrink: 0; margin-right: 8px; display: flex; align-items: center; margin-top: 4px; }
|
||||||
|
ul[data-type="taskList"] li > label > input[type="checkbox"] { width: 16px; height: 16px; cursor: default; }
|
||||||
|
ul[data-type="taskList"] li > div { flex: 1; }
|
||||||
$darkModeCss
|
$darkModeCss
|
||||||
</style>
|
</style>
|
||||||
<script>
|
<script>
|
||||||
@@ -2492,6 +2507,15 @@ class PageEditorActivity : AppCompatActivity() {
|
|||||||
content
|
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") {
|
if (json.isNotEmpty() && json != "null") {
|
||||||
|
|||||||
@@ -285,6 +285,14 @@ class SpacesActivity : AppCompatActivity() {
|
|||||||
showCachedPagesDialog()
|
showCachedPagesDialog()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
binding.btnSyncAll.setOnClickListener {
|
||||||
|
startSyncAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
binding.btnCleanCache.setOnClickListener {
|
||||||
|
showCleanCacheDialog()
|
||||||
|
}
|
||||||
|
|
||||||
binding.btnDeletedPages.setOnClickListener {
|
binding.btnDeletedPages.setOnClickListener {
|
||||||
showDeletedPagesDialog()
|
showDeletedPagesDialog()
|
||||||
}
|
}
|
||||||
@@ -1651,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()) {
|
||||||
@@ -1658,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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
val gson = Gson()
|
if (history.content != null) {
|
||||||
val json = gson.toJson(history.content)
|
val content = history.content
|
||||||
originalTipTapJson = json
|
if (content is String && content.trimStart().startsWith("<")) {
|
||||||
ContentConverter.tipTapToHtml(json)
|
htmlContent = content
|
||||||
|
originalTipTapJson = ""
|
||||||
|
} else if (content is Map<*, *>) {
|
||||||
|
val gson = Gson()
|
||||||
|
originalTipTapJson = gson.toJson(content)
|
||||||
|
htmlContent = ContentConverter.contentToHtml(content)
|
||||||
|
} else if (content is String) {
|
||||||
|
originalTipTapJson = content
|
||||||
|
htmlContent = ContentConverter.tipTapToHtml(content)
|
||||||
|
} else {
|
||||||
|
htmlContent = "<p>Sin contenido</p>"
|
||||||
|
originalTipTapJson = ""
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
"<p>Sin contenido</p>"
|
htmlContent = "<p>Sin contenido</p>"
|
||||||
|
originalTipTapJson = ""
|
||||||
}
|
}
|
||||||
|
|
||||||
withContext(Dispatchers.Main) {
|
withContext(Dispatchers.Main) {
|
||||||
|
|||||||
@@ -1,16 +1,16 @@
|
|||||||
package com.docmost.app.utils
|
package com.docmost.app.utils
|
||||||
|
|
||||||
import com.google.gson.Gson
|
import com.google.gson.Gson
|
||||||
import com.google.gson.JsonObject
|
import com.google.gson.reflect.TypeToken
|
||||||
|
|
||||||
object ContentConverter {
|
object ContentConverter {
|
||||||
private val gson = Gson()
|
private val gson = Gson()
|
||||||
|
|
||||||
fun tipTapJsonToHtml(tipTapJson: String): String {
|
fun tipTapJsonToHtml(tipTapJson: String): String {
|
||||||
return try {
|
return try {
|
||||||
val doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
|
val type = object : TypeToken<Map<String, Any?>>() {}.type
|
||||||
@Suppress("UNCHECKED_CAST")
|
val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
|
||||||
nodeToHtml(doc as Map<String, Any?>)
|
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 doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
|
val type = object : TypeToken<Map<String, Any?>>() {}.type
|
||||||
@Suppress("UNCHECKED_CAST")
|
val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
|
||||||
nodeToHtml(doc as Map<String, Any?>)
|
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<*, *>) {
|
||||||
@@ -114,6 +140,29 @@ object ContentConverter {
|
|||||||
}
|
}
|
||||||
sb.append("</li>")
|
sb.append("</li>")
|
||||||
}
|
}
|
||||||
|
"taskList" -> {
|
||||||
|
sb.append("<ul data-type=\"taskList\">")
|
||||||
|
content?.forEach { child ->
|
||||||
|
if (child is Map<*, *>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
sb.append(nodeToHtml(child as Map<String, Any?>))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("</ul>")
|
||||||
|
}
|
||||||
|
"taskItem" -> {
|
||||||
|
val checked = (node["attrs"] as? Map<*, *>)?.get("checked") as? Boolean ?: false
|
||||||
|
val checkedAttr = if (checked) " checked" else ""
|
||||||
|
sb.append("<li data-type=\"taskItem\" data-checked=\"$checked\">")
|
||||||
|
sb.append("<label><input type=\"checkbox\"$checkedAttr></label>")
|
||||||
|
content?.forEach { child ->
|
||||||
|
if (child is Map<*, *>) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
sb.append(nodeToHtml(child as Map<String, Any?>))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sb.append("</li>")
|
||||||
|
}
|
||||||
"codeBlock" -> {
|
"codeBlock" -> {
|
||||||
sb.append("<pre><code>")
|
sb.append("<pre><code>")
|
||||||
content?.forEach { child ->
|
content?.forEach { child ->
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
57
app/src/main/res/layout/dialog_cached_pages.xml
Normal file
57
app/src/main/res/layout/dialog_cached_pages.xml
Normal 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>
|
||||||
45
app/src/main/res/layout/item_cached_page.xml
Normal file
45
app/src/main/res/layout/item_cached_page.xml
Normal 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>
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user