Add native document sharing and Unicode word counts
10 files changed,
+366
−0
internal/docs/native_review_test.go+97 −0internal/docs/share.go+4 −0native/apple/Package.swift+1 −0native/apple/Sources/DocsKit/DocsShareGrant.swift+7 −0native/apple/Sources/DocsKit/DocsShareInformation.swift+10 −0native/apple/Sources/DocsKit/DocsShareMutation.swift+5 −0native/apple/Sources/DocsKit/DocsSharingView.swift+130 −0native/apple/Sources/DocsKit/DocsToolsControls.swift+42 −0native/apple/Sources/DocsKit/DocumentWordCounts.swift+44 −0native/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) |
| + } |
| +} |