oficina / docs Public

Add native document sharing and Unicode word counts

Paul Campbell pushed by codex agent, operated by paul@keymail.dev a2ce3c022dac9fee26d496c1df154ac579381480 parent 7d71f2e
10 files changed, +366 −0
  • internal/docs/native_review_test.go +97 −0
  • internal/docs/share.go +4 −0
  • native/apple/Package.swift +1 −0
  • native/apple/Sources/DocsKit/DocsShareGrant.swift +7 −0
  • native/apple/Sources/DocsKit/DocsShareInformation.swift +10 −0
  • native/apple/Sources/DocsKit/DocsShareMutation.swift +5 −0
  • native/apple/Sources/DocsKit/DocsSharingView.swift +130 −0
  • native/apple/Sources/DocsKit/DocsToolsControls.swift +42 −0
  • native/apple/Sources/DocsKit/DocumentWordCounts.swift +44 −0
  • native/apple/Tests/DocsKitTests/DocumentWordCountsTests.swift +26 −0
diff --git a/internal/docs/native_review_test.go b/internal/docs/native_review_test.go
new file mode 100644
index 0000000..be1578b
--- /dev/null
+++ b/internal/docs/native_review_test.go
@@ -0,0 +1,97 @@
+package docs_test
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "encoding/base64"
+ "encoding/json"
+ "net/http"
+ "net/http/httptest"
+ "os"
+ "testing"
+ "time"
+
+ "github.com/carlosframework/docs/internal/docs"
+)
+
+// This optional fixture exists only in the test binary. The production app
+// never reads review credentials, bypasses origin checks, or seeds sample data.
+func TestNativeReviewFixture(t *testing.T) {
+ input, stop := os.Getenv("DOCS_NATIVE_REVIEW_INPUT"), os.Getenv("DOCS_NATIVE_REVIEW_STOP")
+ if input == "" || stop == "" {
+ t.Skip("interactive native review fixture not requested")
+ }
+ app := members(t, "native-review@example.test")
+ browser := signInCookies(t, app, "native-review@example.test")
+ sqid := createDoc(t, app, browser, "Native review document")
+ createDoc(t, app, browser, "Second review document")
+ owner := app.TestPerson(t, "native-review@example.test")
+ doc, err := app.Store().Get(t.Context(), owner, sqid)
+ if err != nil {
+ t.Fatal(err)
+ }
+ update, err := os.ReadFile("../../fixtures/vectors/fragment/formatted.update.bin")
+ if err != nil {
+ t.Fatal(err)
+ }
+ if _, err := app.Store().ApplyUpdate(t.Context(), doc, app.TestActorFor(t, doc), update); err != nil {
+ t.Fatal(err)
+ }
+ verifier := base64.RawURLEncoding.EncodeToString(bytes.Repeat([]byte{17}, 32))
+ hash := sha256.Sum256([]byte(verifier))
+ begin := nativeRequest(app, nil, "POST", "/native/request", `{"challenge":"`+base64.RawURLEncoding.EncodeToString(hash[:])+`"}`)
+ var pending struct{ ID string }
+ if begin.Code != 200 || json.Unmarshal(begin.Body.Bytes(), &pending) != nil {
+ t.Fatal("review native request failed")
+ }
+ if approval := nativeRequest(app, browser, "POST", "/native/authorize?id="+pending.ID, ""); approval.Code != 200 {
+ t.Fatal("review native approval failed")
+ }
+ exchanged := nativeRequest(app, nil, "POST", "/native/exchange", `{"id":"`+pending.ID+`","verifier":"`+verifier+`"}`)
+ var credential map[string]string
+ if exchanged.Code != 200 || json.Unmarshal(exchanged.Body.Bytes(), &credential) != nil || credential["value"] == "" {
+ t.Fatal("review native exchange failed")
+ }
+ server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ // The ephemeral listener and canonical test origin differ. Only this
+ // isolated fixture adapts the Origin header; normal routes stay guarded.
+ r.Header.Set("Origin", docs.DefaultOrigin)
+ r.Header.Del("Sec-Fetch-Site")
+ app.Handler().ServeHTTP(w, r)
+ }))
+ defer server.Close()
+ defer server.CloseClientConnections()
+ data, err := json.Marshal(map[string]any{"origin": server.URL, "credential": credential, "sqid": sqid})
+ if err != nil {
+ t.Fatal(err)
+ }
+ file, err := os.OpenFile(input, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600)
+ if err != nil {
+ t.Fatal(err)
+ }
+ defer os.Remove(input)
+ if _, err := file.Write(data); err != nil {
+ file.Close()
+ t.Fatal(err)
+ }
+ if err := file.Close(); err != nil {
+ t.Fatal(err)
+ }
+ t.Log("Isolated native Docs review fixture ready")
+ deadline := time.NewTimer(10 * time.Minute)
+ defer deadline.Stop()
+ tick := time.NewTicker(200 * time.Millisecond)
+ defer tick.Stop()
+ for {
+ select {
+ case <-t.Context().Done():
+ return
+ case <-deadline.C:
+ t.Fatal("native review fixture timed out")
+ case <-tick.C:
+ if _, err := os.Stat(stop); err == nil {
+ return
+ }
+ }
+ }
+}
diff --git a/internal/docs/share.go b/internal/docs/share.go
index 35554f0..2bcfd0c 100644
--- a/internal/docs/share.go
+++ b/internal/docs/share.go
@@ -96,6 +96,10 @@ func (h *handlers) renderShare(w http.ResponseWriter, r *http.Request, doc *Docu
published, err := h.store.IsPublished(r.Context(), doc.OwnerID, doc.Sqid)
if err != nil {
h.logger.Error("docs: read published state", "sqid", doc.Sqid, "err", err)
+ if wantsNative(r) {
+ http.Error(w, rastrillo.T(r, "docs.error.generic"), http.StatusServiceUnavailable)
+ return
+ }
if h.store.team != nil {
h.ownerLookupFailure(w, r, err)
return
diff --git a/native/apple/Package.swift b/native/apple/Package.swift
index 1b139a9..ea23409 100644
--- a/native/apple/Package.swift
+++ b/native/apple/Package.swift
@@ -7,4 +7,5 @@ let package = Package(name: "DocsNative", defaultLocalization: "en", platforms:
.binaryTarget(name: "DocsCRDT", path: "Artifacts/DocsCRDT.xcframework"),
.target(name: "DocsKit", dependencies: ["DocsCRDT", .product(name: "RastrilloApple", package: "rastrillo-native-editors")], resources: [.process("Resources")]),
.executableTarget(name: "DocsMac", dependencies: ["DocsKit", .product(name: "RastrilloApple", package: "rastrillo-native-editors")], path: "Sources/DocsApp"),
+ .testTarget(name: "DocsKitTests", dependencies: ["DocsKit"]),
])
diff --git a/native/apple/Sources/DocsKit/DocsShareGrant.swift b/native/apple/Sources/DocsKit/DocsShareGrant.swift
new file mode 100644
index 0000000..44fd5de
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsShareGrant.swift
@@ -0,0 +1,7 @@
+import Foundation
+
+struct DocsShareGrant: Decodable, Identifiable {
+ let address: String
+ let role: String
+ var id: String { address }
+}
diff --git a/native/apple/Sources/DocsKit/DocsShareInformation.swift b/native/apple/Sources/DocsKit/DocsShareInformation.swift
new file mode 100644
index 0000000..104b620
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsShareInformation.swift
@@ -0,0 +1,10 @@
+import Foundation
+
+struct DocsShareInformation: Decodable {
+ let sqid: String
+ let title: String
+ let grants: [DocsShareGrant]
+ let published: Bool
+ let public_url: String
+ let error: String
+}
diff --git a/native/apple/Sources/DocsKit/DocsShareMutation.swift b/native/apple/Sources/DocsKit/DocsShareMutation.swift
new file mode 100644
index 0000000..bfe85ea
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsShareMutation.swift
@@ -0,0 +1,5 @@
+struct DocsShareMutation: Decodable {
+ let ok: Bool?
+ let published: Bool?
+ let public_url: String?
+}
diff --git a/native/apple/Sources/DocsKit/DocsSharingView.swift b/native/apple/Sources/DocsKit/DocsSharingView.swift
new file mode 100644
index 0000000..00b8f50
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsSharingView.swift
@@ -0,0 +1,130 @@
+import RastrilloApple
+import SwiftUI
+
+@MainActor struct DocsSharingView: View {
+ let client: NativeHTTP
+ let sqid: String
+ @Environment(\.dismiss) private var dismiss
+ @State private var information: DocsShareInformation?
+ @State private var address = ""
+ @State private var role = "reader"
+ @State private var busy = false
+ @State private var needsRefresh = true
+ @State private var errorKey: String?
+ @State private var oneTimeURL: URL?
+
+ var body: some View {
+ NavigationStack {
+ Form {
+ if let errorKey {
+ Section {
+ Text(LocalizedStringKey(errorKey), bundle: DocsResources.bundle)
+ Button { Task { await refresh() } } label: { label("native.retry") }
+ .disabled(busy)
+ }
+ }
+ if busy { ProgressView() }
+ if let information {
+ Section {
+ if information.grants.isEmpty { label("docs.share.empty") }
+ ForEach(information.grants) { grant in
+ VStack(alignment: .leading) {
+ Text(verbatim: grant.address).textSelection(.enabled)
+ label(grant.role == "writer" ? "docs.share.role.writer" : "docs.share.role.reader")
+ .foregroundStyle(.secondary)
+ Button(role: .destructive) {
+ Task { await change("/revoke", values: ["address": grant.address]) }
+ } label: { label("docs.share.revoke") }
+ .disabled(busy || needsRefresh)
+ }
+ }
+ } header: { label("docs.share.heading") }
+ Section {
+ TextField(String(localized: "docs.share.address", bundle: DocsResources.bundle), text: $address)
+ .autocorrectionDisabled()
+ #if os(iOS)
+ .textInputAutocapitalization(.never)
+ .keyboardType(.emailAddress)
+ #endif
+ Picker(selection: $role) {
+ label("docs.share.role.reader").tag("reader")
+ label("docs.share.role.writer").tag("writer")
+ } label: { label("docs.share.role") }
+ Button { Task { await change("", values: ["address": address, "role": role]) } } label: { label("docs.share.submit") }
+ .disabled(busy || needsRefresh || address.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty)
+ }.disabled(busy)
+ Section {
+ label("docs.share.public.body")
+ if let oneTimeURL {
+ Text(verbatim: oneTimeURL.absoluteString).textSelection(.enabled)
+ ShareLink(item: oneTimeURL) { label("docs.share.submit") }
+ label("docs.share.public.once").foregroundStyle(.secondary)
+ } else if information.published {
+ label("docs.share.public.published").foregroundStyle(.secondary)
+ }
+ if information.published {
+ Button { Task { await change("/link", values: ["on": "1"]) } } label: { label("docs.share.public.replace") }
+ Button(role: .destructive) { Task { await change("/link", values: ["on": "0"]) } } label: { label("docs.share.public.off") }
+ } else {
+ Button { Task { await change("/link", values: ["on": "1"]) } } label: { label("docs.share.public.on") }
+ }
+ } header: { label("docs.share.public.heading") }
+ footer: { label("docs.share.public.hint") }
+ .disabled(busy || needsRefresh)
+ }
+ }
+ .formStyle(.grouped)
+ .navigationTitle(Text("docs.share.title", bundle: DocsResources.bundle))
+ .toolbar { ToolbarItem(placement: .confirmationAction) {
+ Button { dismiss() } label: { label("docs.share.back") }.disabled(busy)
+ } }
+ .task(id: sqid) { await refresh() }
+ .interactiveDismissDisabled(busy)
+ #if os(macOS)
+ .frame(minWidth: 460, idealWidth: 540, minHeight: 520)
+ #endif
+ }
+ }
+
+ private func label(_ key: String) -> Text { Text(LocalizedStringKey(key), bundle: DocsResources.bundle) }
+
+ private func load() async throws {
+ let state: DocsShareInformation = try await client.request("/d/\(sqid)/share")
+ information = state
+ if !state.published { oneTimeURL = nil }
+ else if !state.public_url.isEmpty { oneTimeURL = URL(string: state.public_url) }
+ needsRefresh = false
+ }
+
+ private func refresh() async {
+ guard !busy else { return }
+ busy = true
+ defer { busy = false }
+ errorKey = nil
+ do { try await load() }
+ catch { needsRefresh = true; errorKey = "native.share.loadFailed" }
+ }
+
+ private func change(_ suffix: String, values: [String: String]) async {
+ guard !busy && !needsRefresh else { return }
+ busy = true
+ errorKey = nil
+ defer { busy = false }
+ // URL query encoding leaves '+' literal; forms interpret it as space.
+ var components = URLComponents()
+ components.queryItems = values.sorted { $0.key < $1.key }.map { URLQueryItem(name: $0.key, value: $0.value) }
+ let body = Data((components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B").utf8)
+ do {
+ let result: DocsShareMutation = try await client.request("/d/\(sqid)/share\(suffix)", method: "POST", body: body, contentType: "application/x-www-form-urlencoded")
+ if suffix == "/link" {
+ guard let published = result.published else { throw NativeClientError.message("Invalid sharing response") }
+ oneTimeURL = published ? result.public_url.flatMap(URL.init(string:)) : nil
+ } else {
+ guard result.ok == true else { throw NativeClientError.message("Invalid sharing response") }
+ if suffix.isEmpty { address = "" }
+ }
+ do { try await load() }
+ catch { needsRefresh = true; errorKey = "native.share.loadFailed" }
+ } catch { needsRefresh = true; errorKey = "native.share.saveFailed" }
+ }
+}
diff --git a/native/apple/Sources/DocsKit/DocsToolsControls.swift b/native/apple/Sources/DocsKit/DocsToolsControls.swift
new file mode 100644
index 0000000..ce3e83d
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocsToolsControls.swift
@@ -0,0 +1,42 @@
+import SwiftUI
+
+@MainActor
+struct DocsToolsControls: View {
+ let projection: CRDTProjection
+ let content: NSAttributedString
+ let selection: NSRange
+ @State private var showingCount = false
+
+ var body: some View {
+ Button { showingCount = true } label: {
+ Label { Text("docs.editor.wordcount.title", bundle: DocsResources.bundle) } icon: { Image(systemName: "number") }
+ }
+ .sheet(isPresented: $showingCount) {
+ let count = DocumentWordCounts(projection: projection, content: content, selection: selection)
+ NavigationStack {
+ Form {
+ row("docs.editor.wordcount.words", count.words)
+ row("docs.editor.wordcount.characters", count.characters)
+ row("docs.editor.wordcount.charactersnospaces", count.charactersNoSpaces)
+ row("docs.editor.wordcount.paragraphs", count.paragraphs)
+ if let selected = count.selectedWords { row("docs.editor.wordcount.selection", selected) }
+ }
+ .navigationTitle(Text("docs.editor.wordcount.title", bundle: DocsResources.bundle))
+ .toolbar {
+ ToolbarItem(placement: .confirmationAction) {
+ Button { showingCount = false } label: { Text("docs.editor.wordcount.close", bundle: DocsResources.bundle) }
+ }
+ }
+ }
+ #if os(macOS)
+ .frame(minWidth: 360, minHeight: 290)
+ #else
+ .presentationDetents([.medium])
+ #endif
+ }
+ }
+
+ private func row(_ key: LocalizedStringKey, _ value: Int) -> some View {
+ LabeledContent { Text(value, format: .number).monospacedDigit() } label: { Text(key, bundle: DocsResources.bundle) }
+ }
+}
diff --git a/native/apple/Sources/DocsKit/DocumentWordCounts.swift b/native/apple/Sources/DocsKit/DocumentWordCounts.swift
new file mode 100644
index 0000000..1d231de
--- /dev/null
+++ b/native/apple/Sources/DocsKit/DocumentWordCounts.swift
@@ -0,0 +1,44 @@
+import Foundation
+
+struct DocumentWordCounts {
+ let words: Int
+ let characters: Int
+ let charactersNoSpaces: Int
+ let paragraphs: Int
+ let selectedWords: Int?
+
+ init(projection: CRDTProjection, content: NSAttributedString, selection: NSRange) {
+ var whole = ""
+ func visit(_ node: CRDTNode) {
+ if node.type == "text" { whole += node.plainText }
+ else {
+ if !["image", "hard_break"].contains(node.type), !whole.isEmpty { whole += " " }
+ for child in node.children ?? [] { visit(child) }
+ }
+ }
+ for node in projection.children { visit(node) }
+ words = Self.countWords(whole)
+ characters = whole.utf16.count
+ charactersNoSpaces = whole.unicodeScalars.filter { !CharacterSet.whitespacesAndNewlines.contains($0) }.reduce(0) { $0 + $1.utf16.count }
+ paragraphs = projection.children.filter {
+ ["paragraph", "heading", "code_block", "bullet_list", "ordered_list", "blockquote"].contains($0.type)
+ }.count
+ let valid = NSIntersectionRange(selection, NSRange(location: 0, length: content.length))
+ if valid.length == 0 { selectedWords = nil }
+ else {
+ var selected = ""
+ content.enumerateAttributes(in: valid) { attributes, range, _ in
+ if attributes[.docsNode] != nil {
+ selected += (content.string as NSString).substring(with: range)
+ } else if attributes[.docsBlock] != nil { selected += " " }
+ }
+ selectedWords = Self.countWords(selected)
+ }
+ }
+
+ static func countWords(_ text: String) -> Int {
+ var count = 0
+ text.enumerateSubstrings(in: text.startIndex..<text.endIndex, options: [.byWords, .substringNotRequired]) { _, _, _, _ in count += 1 }
+ return count
+ }
+}
diff --git a/native/apple/Tests/DocsKitTests/DocumentWordCountsTests.swift b/native/apple/Tests/DocsKitTests/DocumentWordCountsTests.swift
new file mode 100644
index 0000000..d1b75b8
--- /dev/null
+++ b/native/apple/Tests/DocsKitTests/DocumentWordCountsTests.swift
@@ -0,0 +1,26 @@
+import Foundation
+import XCTest
+@testable import DocsKit
+
+final class DocumentWordCountsTests: XCTestCase {
+ func testUnicodeWordsAreNotWhitespaceChunks() {
+ XCTAssertEqual(DocumentWordCounts.countWords("Hello,world!"), 2)
+ XCTAssertGreaterThan(DocumentWordCounts.countWords("私は日本語を話します"), 1)
+ XCTAssertEqual(DocumentWordCounts.countWords(" — … 😀"), 0)
+ }
+
+ @MainActor func testDocumentSemanticsAndSelectedText() throws {
+ let data = Data(#"{"children":[{"id":"p1","type":"paragraph","children":[{"id":"t1","type":"text","runs":[{"insert":"Hi 😀"}]},{"id":"im","type":"image","attrs":{"alt":"never count image alt"}}]},{"id":"list","type":"bullet_list","children":[{"id":"li","type":"list_item","children":[{"id":"p2","type":"paragraph","children":[{"id":"t2","type":"text","runs":[{"insert":"two words"}]}]}]}]},{"id":"rule","type":"horizontal_rule"}],"canUndo":false,"canRedo":false}"#.utf8)
+ let projection = try JSONDecoder().decode(CRDTProjection.self, from: data)
+ let content = NativeTextProjection.render(projection)
+ let selected = (content.string as NSString).range(of: "two words")
+ let counts = DocumentWordCounts(projection: projection, content: content, selection: selected)
+ XCTAssertEqual(counts.words, 3)
+ // Each visited block separates text, as in the web model walker.
+ XCTAssertEqual(counts.characters, "Hi 😀 two words ".utf16.count)
+ XCTAssertEqual(counts.charactersNoSpaces, "Hi😀twowords".utf16.count)
+ XCTAssertEqual(counts.paragraphs, 2)
+ XCTAssertEqual(counts.selectedWords, 2)
+ XCTAssertNil(DocumentWordCounts(projection: projection, content: content, selection: NSRange(location: 0, length: 0)).selectedWords)
+ }
+}