|
| 1 | +package com.example.data |
| 2 | + |
| 3 | +import com.example.data.model.Bookmark |
| 4 | +import org.json.JSONArray |
| 5 | +import org.json.JSONObject |
| 6 | +import java.util.regex.Pattern |
| 7 | + |
| 8 | +object BookmarkExportHelper { |
| 9 | + |
| 10 | + fun exportToNetscapeHtml(bookmarks: List<Bookmark>, profileName: String = "Feather"): String { |
| 11 | + val sb = StringBuilder() |
| 12 | + val nowSec = System.currentTimeMillis() / 1000 |
| 13 | + |
| 14 | + sb.append("<!DOCTYPE NETSCAPE-Bookmark-file-1>\n") |
| 15 | + sb.append("<!-- This is an automatically generated file. It will be read and overwritten. Do Not Edit! -->\n") |
| 16 | + sb.append("<META HTTP-EQUIV=\"Content-Type\" CONTENT=\"text/html; charset=UTF-8\">\n") |
| 17 | + sb.append("<TITLE>Bookmarks</TITLE>\n") |
| 18 | + sb.append("<H1>Bookmarks</H1>\n") |
| 19 | + sb.append("<DL><p>\n") |
| 20 | + sb.append(" <DT><H3 ADD_DATE=\"$nowSec\" LAST_MODIFIED=\"$nowSec\">") |
| 21 | + sb.append(escapeHtml(profileName)).append(" Bookmarks</H3>\n") |
| 22 | + sb.append(" <DL><p>\n") |
| 23 | + |
| 24 | + for (bm in bookmarks) { |
| 25 | + val addDate = if (bm.createdAt > 0) bm.createdAt / 1000 else nowSec |
| 26 | + val safeUrl = escapeHtml(bm.url) |
| 27 | + val safeTitle = escapeHtml(if (bm.title.isNotBlank()) bm.title else bm.url) |
| 28 | + sb.append(" <DT><A HREF=\"").append(safeUrl) |
| 29 | + .append("\" ADD_DATE=\"").append(addDate) |
| 30 | + .append("\">").append(safeTitle).append("</A>\n") |
| 31 | + } |
| 32 | + |
| 33 | + sb.append(" </DL><p>\n") |
| 34 | + sb.append("</DL><p>\n") |
| 35 | + return sb.toString() |
| 36 | + } |
| 37 | + |
| 38 | + fun parseBookmarks(rawContent: String, targetProfileId: String): List<Bookmark> { |
| 39 | + val trimmed = rawContent.trim() |
| 40 | + if (trimmed.isEmpty()) return emptyList() |
| 41 | + |
| 42 | + // 1. Try parsing JSON if content starts with [ or { |
| 43 | + if (trimmed.startsWith("[") || trimmed.startsWith("{")) { |
| 44 | + val jsonResults = parseJsonBookmarks(trimmed, targetProfileId) |
| 45 | + if (jsonResults.isNotEmpty()) return jsonResults |
| 46 | + } |
| 47 | + |
| 48 | + // 2. Parse Netscape HTML / Standard Anchor tags |
| 49 | + return parseHtmlBookmarks(trimmed, targetProfileId) |
| 50 | + } |
| 51 | + |
| 52 | + private fun parseHtmlBookmarks(html: String, targetProfileId: String): List<Bookmark> { |
| 53 | + val results = mutableListOf<Bookmark>() |
| 54 | + val pattern = Pattern.compile( |
| 55 | + "<a\\s+[^>]*?href=[\"']([^\"']+)[\"'][^>]*>(.*?)<\\/a>", |
| 56 | + Pattern.CASE_INSENSITIVE or Pattern.DOTALL |
| 57 | + ) |
| 58 | + val matcher = pattern.matcher(html) |
| 59 | + |
| 60 | + val seenUrls = mutableSetOf<String>() |
| 61 | + val now = System.currentTimeMillis() |
| 62 | + |
| 63 | + while (matcher.find()) { |
| 64 | + val rawUrl = matcher.group(1)?.trim().orEmpty() |
| 65 | + val rawTitle = matcher.group(2)?.trim().orEmpty() |
| 66 | + |
| 67 | + if (rawUrl.isBlank() || rawUrl.startsWith("javascript:", ignoreCase = true)) { |
| 68 | + continue |
| 69 | + } |
| 70 | + |
| 71 | + val unescapedUrl = unescapeHtml(rawUrl) |
| 72 | + val unescapedTitle = unescapeHtml(rawTitle.replace(Regex("<[^>]*>"), "")).ifBlank { unescapedUrl } |
| 73 | + |
| 74 | + if (seenUrls.add(unescapedUrl)) { |
| 75 | + results.add( |
| 76 | + Bookmark( |
| 77 | + profileId = targetProfileId, |
| 78 | + title = unescapedTitle, |
| 79 | + url = unescapedUrl, |
| 80 | + createdAt = now |
| 81 | + ) |
| 82 | + ) |
| 83 | + } |
| 84 | + } |
| 85 | + return results |
| 86 | + } |
| 87 | + |
| 88 | + private fun parseJsonBookmarks(jsonStr: String, targetProfileId: String): List<Bookmark> { |
| 89 | + val results = mutableListOf<Bookmark>() |
| 90 | + val seenUrls = mutableSetOf<String>() |
| 91 | + val now = System.currentTimeMillis() |
| 92 | + |
| 93 | + try { |
| 94 | + val array = if (jsonStr.startsWith("[")) { |
| 95 | + JSONArray(jsonStr) |
| 96 | + } else { |
| 97 | + val obj = JSONObject(jsonStr) |
| 98 | + obj.optJSONArray("bookmarks") ?: obj.optJSONArray("items") ?: JSONArray() |
| 99 | + } |
| 100 | + |
| 101 | + for (i in 0 until array.length()) { |
| 102 | + val item = array.optJSONObject(i) ?: continue |
| 103 | + val url = item.optString("url", "").trim() |
| 104 | + val title = item.optString("title", "").ifBlank { url } |
| 105 | + if (url.isNotBlank() && !url.startsWith("javascript:", ignoreCase = true) && seenUrls.add(url)) { |
| 106 | + results.add( |
| 107 | + Bookmark( |
| 108 | + profileId = targetProfileId, |
| 109 | + title = title, |
| 110 | + url = url, |
| 111 | + createdAt = now |
| 112 | + ) |
| 113 | + ) |
| 114 | + } |
| 115 | + } |
| 116 | + } catch (e: Exception) { |
| 117 | + // Silently fallback if JSON malformed |
| 118 | + } |
| 119 | + return results |
| 120 | + } |
| 121 | + |
| 122 | + private fun escapeHtml(text: String): String { |
| 123 | + return text |
| 124 | + .replace("&", "&") |
| 125 | + .replace("<", "<") |
| 126 | + .replace(">", ">") |
| 127 | + .replace("\"", """) |
| 128 | + .replace("'", "'") |
| 129 | + } |
| 130 | + |
| 131 | + private fun unescapeHtml(text: String): String { |
| 132 | + return text |
| 133 | + .replace(""", "\"") |
| 134 | + .replace("'", "'") |
| 135 | + .replace("'", "'") |
| 136 | + .replace("<", "<") |
| 137 | + .replace(">", ">") |
| 138 | + .replace("&", "&") |
| 139 | + } |
| 140 | +} |
0 commit comments