6 Commits

Author SHA1 Message Date
b640f1f063 v1.0.9: Fix TODO/checklist layout in read-only mode 2026-07-27 21:37:06 -06:00
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
12 changed files with 520 additions and 50 deletions

View File

@@ -20,8 +20,8 @@ android {
applicationId = "com.docmost.app"
minSdk = 26
targetSdk = 34
versionCode = 4
versionName = "1.0.3"
versionCode = 10
versionName = "1.0.9"
}
signingConfigs {
@@ -58,6 +58,13 @@ android {
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"
}
}
}
dependencies {

View File

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

View File

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

@@ -175,6 +175,22 @@ class PageCacheManager(private val context: Context) {
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> {
return try {
if (!indexFile.exists()) return emptyList()

View File

@@ -283,6 +283,7 @@ class PageEditorActivity : AppCompatActivity() {
}
private var isEditorRendered = false
private var isContentLoading = false
private var pendingContent: String? = null
private var isTitleVisible = true
private var isAnimating = false
@@ -627,6 +628,7 @@ class PageEditorActivity : AppCompatActivity() {
@JavascriptInterface
fun onContentChanged(markdown: String) {
if (isContentLoading) return
currentPageContent = markdown
}
@@ -640,12 +642,15 @@ class PageEditorActivity : AppCompatActivity() {
loadEditorContentInternal(content)
pendingContent = null
}
Handler(Looper.getMainLooper()).postDelayed({
isContentLoading = false
}, 600)
}
}
@JavascriptInterface
fun onAutoSave(markdown: String) {
if (!canEdit) return
if (!canEdit || isEditorReadOnly) return
runOnUiThread {
val title = binding.etTitle.text.toString().trim()
@@ -698,14 +703,14 @@ class PageEditorActivity : AppCompatActivity() {
try {
cachedApi.updatePage(
currentPageId, markdown,
if (hasTitleChanges) title else null,
title,
if (hasEmojiChanges) currentEmoji else null
)
val cachedPage = pageCacheManager.getCachedPage(currentPageId)
if (cachedPage != null) {
pageCacheManager.cachePage(cachedPage.copy(
title = if (hasTitleChanges) title else cachedPage.title,
title = title,
icon = if (hasEmojiChanges) currentEmoji else cachedPage.icon,
content = markdown
))
@@ -740,7 +745,7 @@ class PageEditorActivity : AppCompatActivity() {
try {
cachedApi.updatePage(
currentPageId, null,
if (hasTitleChanges) title else null,
title,
if (hasEmojiChanges) currentEmoji else null
)
} catch (e: Exception) {
@@ -768,7 +773,7 @@ class PageEditorActivity : AppCompatActivity() {
cachedApi.updatePage(
pageId!!,
if (hasBodyChanges) markdown else null,
if (hasTitleChanges) title else null,
title,
if (pageEmoji != originalEmoji) pageEmoji else null
)
withContext(Dispatchers.Main) {
@@ -1004,6 +1009,7 @@ class PageEditorActivity : AppCompatActivity() {
private fun loadEditorContent(markdown: String) {
if (BuildConfig.DEBUG) android.util.Log.d("PageEditor", "loadEditorContent called with ${markdown.length} chars")
isContentLoading = true
if (isEditorRendered) {
loadEditorContentInternal(markdown)
} else {
@@ -1877,9 +1883,9 @@ class PageEditorActivity : AppCompatActivity() {
private fun tiptapToMarkdown(tiptapJson: String): String {
return try {
val doc = gson.fromJson(tiptapJson, com.google.gson.JsonObject::class.java).asMap()
@Suppress("UNCHECKED_CAST")
extractText(doc as Map<String, Any?>)
val type = object : com.google.gson.reflect.TypeToken<Map<String, Any?>>() {}.type
val doc: Map<String, Any?> = gson.fromJson(tiptapJson, type)
extractText(doc)
} catch (e: Exception) {
tiptapJson
}
@@ -2194,6 +2200,10 @@ class PageEditorActivity : AppCompatActivity() {
a {
color: #64b5f6 !important;
}
ul[data-type="taskList"] li > label > input[type="checkbox"] {
border-color: #666 !important;
accent-color: #369FFF;
}
""" else ""
return """
@@ -2229,6 +2239,11 @@ class PageEditorActivity : AppCompatActivity() {
th, td { border: 1px solid #ddd; padding: 8px; white-space: nowrap; }
th { background: #f4f4f4; }
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
</style>
<script>
@@ -2492,6 +2507,15 @@ class PageEditorActivity : AppCompatActivity() {
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 {
val json = gson.toJson(content)
if (json.isNotEmpty() && json != "null") {

View File

@@ -285,6 +285,14 @@ class SpacesActivity : AppCompatActivity() {
showCachedPagesDialog()
}
binding.btnSyncAll.setOnClickListener {
startSyncAll()
}
binding.btnCleanCache.setOnClickListener {
showCleanCacheDialog()
}
binding.btnDeletedPages.setOnClickListener {
showDeletedPagesDialog()
}
@@ -1651,6 +1659,79 @@ class SpacesActivity : AppCompatActivity() {
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() {
val cachedPages = pageCacheManager.getCachedPages()
if (cachedPages.isEmpty()) {
@@ -1658,39 +1739,83 @@ class SpacesActivity : AppCompatActivity() {
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 rvResults = view.findViewById<androidx.recyclerview.widget.RecyclerView>(R.id.rvSearchResults)
val tvEmpty = view.findViewById<TextView>(R.id.tvSearchEmpty)
etSearch.visibility = View.GONE
tvEmpty.visibility = View.GONE
val rvResults = view.findViewById<RecyclerView>(R.id.rvCachedPages)
val tvEmpty = view.findViewById<TextView>(R.id.tvEmpty)
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)
val results = cachedPages.toMutableList()
val adapter = object : androidx.recyclerview.widget.RecyclerView.Adapter<androidx.recyclerview.widget.RecyclerView.ViewHolder>() {
override fun getItemCount() = results.size
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): androidx.recyclerview.widget.RecyclerView.ViewHolder {
val v = layoutInflater.inflate(android.R.layout.simple_list_item_2, parent, false)
return object : androidx.recyclerview.widget.RecyclerView.ViewHolder(v) {}
val dateFormat = java.text.SimpleDateFormat("dd MMM yyyy", java.util.Locale.getDefault())
fun formatDate(millis: Long): String {
return try {
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)
val text2 = holder.itemView.findViewById<android.widget.TextView>(android.R.id.text2)
text1.text = if (r.icon.isNullOrBlank()) r.title else "${r.icon} ${r.title}"
text2.text = r.spaceId ?: getString(R.string.offline_mode)
}
fun updateList(pages: List<com.docmost.app.data.CachedPageMeta>) {
filteredPages.clear()
filteredPages.addAll(pages)
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 {
openPageEditor(Page(id = r.pageId, title = r.title, icon = r.icon, spaceId = r.spaceId))
}
}
}
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
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.show()
}

View File

@@ -109,13 +109,26 @@ class VersionViewerActivity : AppCompatActivity() {
CoroutineScope(Dispatchers.IO).launch {
try {
val history = api.getPageHistoryInfo(pageId, historyId)
val htmlContent = if (history.content != null) {
val gson = Gson()
val json = gson.toJson(history.content)
originalTipTapJson = json
ContentConverter.tipTapToHtml(json)
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()
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 {
"<p>Sin contenido</p>"
htmlContent = "<p>Sin contenido</p>"
originalTipTapJson = ""
}
withContext(Dispatchers.Main) {

View File

@@ -1,16 +1,16 @@
package com.docmost.app.utils
import com.google.gson.Gson
import com.google.gson.JsonObject
import com.google.gson.reflect.TypeToken
object ContentConverter {
private val gson = Gson()
fun tipTapJsonToHtml(tipTapJson: String): String {
return try {
val doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
@Suppress("UNCHECKED_CAST")
nodeToHtml(doc as Map<String, Any?>)
val type = object : TypeToken<Map<String, Any?>>() {}.type
val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
nodeToHtml(doc)
} catch (e: Exception) {
tipTapJson
}
@@ -18,14 +18,34 @@ object ContentConverter {
fun tipTapToHtml(tipTapJson: String): String {
return try {
val doc = gson.fromJson(tipTapJson, JsonObject::class.java).asMap()
@Suppress("UNCHECKED_CAST")
nodeToHtml(doc as Map<String, Any?>)
val type = object : TypeToken<Map<String, Any?>>() {}.type
val doc: Map<String, Any?> = gson.fromJson(tipTapJson, type)
nodeToHtml(doc)
} catch (e: Exception) {
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 {
val type = node["type"] as? String
@@ -74,7 +94,13 @@ object ContentConverter {
sb.append("</p>")
}
"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>")
content?.forEach { child ->
if (child is Map<*, *>) {
@@ -114,6 +140,29 @@ object ContentConverter {
}
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" -> {
sb.append("<pre><code>")
content?.forEach { child ->

View File

@@ -471,13 +471,31 @@
android:text="@string/force_offline"
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
android:id="@+id/btnCachedPages"
android:layout_width="match_parent"
android:layout_height="48dp"
android:text="@string/cached_pages"
android:layout_marginBottom="8dp"
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>
</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_description">Ver y restaurar páginas eliminadas recientemente</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>