Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -447,7 +447,7 @@ class DocumentationContentSource(
}

database.rawQuery(CONTENT_QUERY, arrayOf(path)).use { cursor ->
if (cursor.count == 0) return DocumentationLookup.NotFound
if (cursor.count == 0) return renderJsTopic(database, path) ?: DocumentationLookup.NotFound
if (cursor.count != 1) return DocumentationLookup.Ambiguous(cursor.count)

cursor.moveToFirst()
Expand All @@ -464,6 +464,33 @@ class DocumentationContentSource(
}
}

/**
* Help+Manual's in-page navigation loads `jstopics/X.js`, a JavaScript copy of topic `X`. The
* documentation pipeline stores each topic once, as the JSON its page `X.html` renders from, and
* drops the copies (ADFA-6618), so a miss for one is rendered from that JSON with the
* [JSTOPIC_TEMPLATE] template.
*
* @return The rendered copy, or null -- the request stays a plain miss -- when [path] is not a
* `jstopics` file, its page is not one templated row, or the database has no [JSTOPIC_TEMPLATE].
*/
private fun renderJsTopic(
database: SQLiteDatabase,
path: String,
): DocumentationLookup? {
val match = JSTOPIC_PATH.matchEntire(path) ?: return null
val pagePath = match.groupValues[1] + match.groupValues[2] + ".html"

database.rawQuery(CONTENT_QUERY, arrayOf(pagePath)).use { cursor ->
if (cursor.count != 1 || !cursor.moveToFirst() || cursor.getInt(3) <= 0) return null
val hasTemplate = database.rawQuery(TEMPLATE_EXISTS_QUERY, arrayOf(JSTOPIC_TEMPLATE)).use { it.count > 0 }
if (!hasTemplate) return null

val chunks = readChunks(database, pagePath, cursor.getBlob(0))
val json = if (cursor.getString(2) == "brotli") decompressBrotli(database, chunks) else joinChunks(chunks)
return DocumentationLookup.Found(DocumentationContent(renderNamed(JSTOPIC_TEMPLATE, json, path), JSTOPIC_MIME_TYPE))
}
}

/**
* Renders a template using the provided JSON context.
*
Expand Down Expand Up @@ -787,5 +814,11 @@ class DocumentationContentSource(
"""

private const val CHUNK_QUERY = "SELECT content FROM Content WHERE path = ? AND languageId = 1"

// A Help+Manual topic's JavaScript copy, beside its page: i/jstopics/X.js for i/X.html.
private val JSTOPIC_PATH = Regex("""(.*/)?jstopics/([^/]+)\.js""")
private const val JSTOPIC_TEMPLATE = "helpmanual-jstopic.peb"
private const val JSTOPIC_MIME_TYPE = "text/javascript"
private const val TEMPLATE_EXISTS_QUERY = "SELECT 1 FROM Templates WHERE name = ?"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -426,6 +426,90 @@ class DocumentationContentSourceTest {
assertThat(source().lookup("k/html/basic-syntax.html")).isInstanceOf(DocumentationLookup.Failed::class.java)
}

// ADFA-6618: the pipeline drops Help+Manual's jstopics copies, and the page's JSON stands in.
@Test
fun `a missing jstopics file is rendered from its page's JSON with the jstopics template`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase()

val lookup = source().lookup("i/jstopics/debug-enable.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("""hmLoadTopic({hmTitle:"Enable the debugger"});""")
assertThat(lookup.content.mimeType).isEqualTo("text/javascript")
}

@Test
fun `a stored jstopics row is served as it is, with no rendering`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(storedCopy = "stored".toByteArray())

val lookup = source().lookup("i/jstopics/debug-enable.js") as DocumentationLookup.Found

assertThat(lookup.content.bytes.toString(Charsets.UTF_8)).isEqualTo("stored")
}

@Test
fun `a jstopics miss stays not found when the database has no jstopics template`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(jsTopicTemplate = null)

assertThat(source().lookup("i/jstopics/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a jstopics miss stays not found when its page is plain HTML`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase(pageTemplateId = 0)

assertThat(source().lookup("i/jstopics/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a jstopics miss stays not found when it has no page`() {
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns jsTopicDatabase()

assertThat(source().lookup("i/jstopics/no-such-topic.js")).isEqualTo(DocumentationLookup.NotFound)
}

@Test
fun `a miss outside jstopics is not looked up as a page`() {
val database = jsTopicDatabase()
every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns database

assertThat(source().lookup("i/js/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
assertThat(source().lookup("i/jstopics/sub/debug-enable.js")).isEqualTo(DocumentationLookup.NotFound)
verify(exactly = 0) { database.rawQuery(match { it.contains("FROM Content") }, match { it[0].endsWith(".html") }) }
}

/**
* A database holding the templated page `i/debug-enable.html` (template [pageTemplateId]), the
* `jstopics` copy [storedCopy] if any, and the `helpmanual-jstopic.peb` template [jsTopicTemplate]
* if any. Every other Content path is missing.
*/
private fun jsTopicDatabase(
pageTemplateId: Int = 5,
jsTopicTemplate: String? = """hmLoadTopic({hmTitle:"{{ hmTitle }}"});""",
storedCopy: ByteArray? = null,
): SQLiteDatabase =
mockk<SQLiteDatabase>(relaxed = true) {
every { rawQuery(match { it.contains("FROM Content") }, any()) } answers { missingRowCursor() }
every { rawQuery(match { it.contains("FROM Content") }, arrayOf("i/debug-enable.html")) } answers {
contentCursor(bytes = """{"hmTitle": "Enable the debugger"}""".toByteArray(), templateId = pageTemplateId)
}
if (storedCopy != null) {
every { rawQuery(match { it.contains("FROM Content") }, arrayOf("i/jstopics/debug-enable.js")) } answers {
contentCursor(bytes = storedCopy, mimeType = "text/javascript")
}
}
every { rawQuery(match { it.contains("WHERE name = ?") }, arrayOf("helpmanual-jstopic.peb")) } answers {
if (jsTopicTemplate == null) {
missingRowCursor()
} else {
mockk(relaxed = true) {
every { count } returns 1
every { moveToFirst() } returns true
every { getBlob(0) } returns jsTopicTemplate.toByteArray()
}
}
}
}

@Test
fun `a newer debug database is swapped in, and the generation says so`() {
val installed = database(contentCursor(bytes = "installed".toByteArray()))
Expand Down
2 changes: 1 addition & 1 deletion docs/documentation-database.md
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ CREATE TABLE Tooltips (

Of the five sites below, only `DocumentationContentSource` and `ToolTipManager` open the file, with `SQLiteDatabase.openDatabase(..., OPEN_READONLY)` — `WebServer` and the interceptor read through the content source, and `PluginDocumentationManager` is the one write path, opening `OPEN_READWRITE` to install plugin content (see ADR 0001 for why raw SQLite is justified here instead of Room).

- **`common/.../documentation/DocumentationContentSource.kt`** — the one Tier 3 pipeline that reads this database: row lookup (a request target is matched raw first, since stored `Content.path` values are percent-encoded, with a percent-decoded fallback on a miss — shared by both transports so they agree on which pages exist), chunked-row reassembly, dictionary-aware Brotli decode gated on the declared documentation version, and the database swaps — to a newer sdcard debug database, and reopening the installed file when an asset install rewrites it in place under the cached handle — under a read/write lock so several threads can read while a swap cannot close the handle under them. It also renders the rows that are Pebble template contexts (`templateId > 0`, the Kotlin doc set's pages) — resolving each template's references to other templates against `Templates.name` as it goes — so both transports below serve finished pages and neither needs the template engine itself. All of that logic exists once (ADFA-5176).
- **`common/.../documentation/DocumentationContentSource.kt`** — the one Tier 3 pipeline that reads this database: row lookup (a request target is matched raw first, since stored `Content.path` values are percent-encoded, with a percent-decoded fallback on a miss — shared by both transports so they agree on which pages exist), chunked-row reassembly, dictionary-aware Brotli decode gated on the declared documentation version, and the database swaps — to a newer sdcard debug database, and reopening the installed file when an asset install rewrites it in place under the cached handle — under a read/write lock so several threads can read while a swap cannot close the handle under them. It also renders the rows that are Pebble template contexts (`templateId > 0`, the Kotlin doc set's pages) — resolving each template's references to other templates against `Templates.name` as it goes — so both transports below serve finished pages and neither needs the template engine itself. All of that logic exists once (ADFA-5176). One more rule renders a row that isn't stored: Help+Manual's in-page navigation loads `jstopics/X.js`, a JavaScript copy of topic `X`, and once the documentation pipeline stores each Help+Manual topic only as the JSON its page `X.html` renders from (ADFA-6618), a miss for `jstopics/X.js` is rendered from that page's JSON with the `helpmanual-jstopic.peb` template and served as `text/javascript`. A stored `jstopics` row still wins, and the miss stays a 404 when the page isn't a templated row or the database has no such template.
- **`common/.../documentation/DocumentationRequestInterceptor.kt`** — serves Tier 3 *in-process* for the app's WebViews (`HelpActivity`, the tooltip fragment, `FAQActivity`), through `WebViewClient.shouldInterceptRequest`, so a page's assets cost a database read instead of a TCP connection each (ADFA-5176). It matches the same `http://localhost:6174/...` URL space, so the strings.xml entries, `ToolTipManager`'s link builder and the `DocumentationExtension` contract need no changes; anything it declines — a `/pr/` endpoint, an unknown path, a failed read — falls through to `WebServer` unchanged. Both transports get their `Content-Type` charset from the same place, `ContentTypeHeaders` (ADFA-5241), so a row does not describe itself differently depending on which one served it. Set `/sdcard/Download/CodeOnTheGo.nointercept` to force documentation back onto the server.
- **`app/.../localWebServer/WebServer.kt`** — serves Tier 3 over HTTP on port 6174, for WebViews that are not wired to the interceptor above and for the `/pr/` developer endpoints. It reads through `DocumentationContentSource`, so it holds no database, template engine or decode logic of its own; what remains here is HTTP: request parsing, the `/pr/` pages, error responses, and the CSS/asset shortcuts. Also serves a Dynamic Bookshelf JSON payload (joining `Content`/`Bookshelf`/`BookCategories`, rendered through the `bookshelf` template) and debug-only HTML dumps at `/pr/db` (`LastChange`, last 20 rows) and `/pr/pr` (recent projects, from a *different* database).
- **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up `Tooltips` joined to `TooltipCategories` by `(category, tag)`, then `TooltipButtons` for the Tier 3 links shown at the bottom.
Expand Down
Loading