oficina / docs Public

Clone
git clone https://amadan.net/oficina/docs

Plain git — no account needed to clone.

Download

Download this file

1import Foundation
2import Observation
3import RastrilloApple
4import RastrilloNative
5
6@MainActor @Observable final class DocsModel {
7 enum ListState { case loading, loaded, failed }
8 var listState = ListState.loading
9 private var listRequest = UUID()
10 var documents: [DocumentSummary] = []
11 var canCreate = false
12 var errorKey: String?
13 var detail: DocumentDetail?
14 var projection: CRDTProjection?
15 var projectionGeneration: UInt64 = 0
16 var renderedGeneration: UInt64 = 0
17 var composing = false
18 var inputPaused = false
19 var explicitCommandRevision: UInt64 = 0
20 var loading = false
21 var creatingDocument = false
22 private var lifetime = UUID()
23 private(set) var draftToken: String?
24 private var creationTask: Task<Void, Never>?
25 private var pendingTitle: String?
26 private var metadataDirty = false
27 var renameInput: String?
28 private var titleRevision: UInt64 = 0
29 var isDraft: Bool { draftToken != nil }
30 var editorIdentity = UUID()
31
32 var selection = NSRange(location: 0, length: 0)
33 var typingMarks: [String: JSONValue]?
34 private var replica: CRDTReplica?
35 private var sequence: Int64 = 0
36 let offlineAccount: DocsOfflineAccount
37 private var persistence: DocsPersistence?
38 private var documentKey: String?
39 private var failedUpdate: Data?
40 private var localFailure: SyncFailure?
41 private var writerTask: Task<Void, Never>?
42 private let writer = CoalescedRunner()
43 private var laneSummary = OutboxSummary()
44 private var summaryTask: Task<Void, Never>?
45 var syncStatus: SyncStatus { offlineAccount.drainer.status(lane: documentKey.map { "doc:" + $0 }, localFailure: localFailure) }
46 var lostChangesNotice: String? { outboxSummary.lostUnsent ? NativeSyncStrings.kit.unsentLost : nil }
47 func dismissLostChangesNotice() async { await offlineAccount.outbox.acknowledgeLoss() }
48 var outboxSummary: OutboxSummary { offlineAccount.drainer.summary }
49
50 private var editing: [[String: JSONValue]] = []
51 private var editingTask: Task<Void, Never>?
52 private var streamTask: Task<Void, Never>?
53 private var saving = false
54 private var documentEpoch = UUID()
55 private let publications = DocumentPublication()
56 /// Resolved on every request, never captured: this model can hold unsent
57 /// edits for longer than one session, and signing in again replaces the
58 /// transport under it. A captured client would keep sending with the ended
59 /// session. nil means no session; requests then fail as `.unauthorized`.
60 private let transport: @MainActor () -> NativeTransport?
61 /// The stream loop waits on this between attempts, so a restored network
62 /// reconnects at once and an offline device makes no timed attempts.
63 let connectivity: NativeConnectivity
64 /// Monotonic seconds for the stream loop's pacing; tests substitute a fake.
65 var reconnectClock: @MainActor () -> TimeInterval = { ProcessInfo.processInfo.systemUptime }
66 init(transport: @escaping @MainActor () -> NativeTransport?, connectivity: NativeConnectivity? = nil, draft: Bool = false, offlineAccount: DocsOfflineAccount? = nil) {
67 self.transport = transport
68 self.connectivity = connectivity ?? .shared
69 self.offlineAccount = offlineAccount ?? DocsOfflineAccount(store: EphemeralDocsStore(), accountID: UUID().uuidString, connectivity: connectivity ?? .shared, transport: transport)
70 if draft { draftToken = UUID().uuidString }
71 }
72 convenience init(client: NativeTransport, connectivity: NativeConnectivity? = nil, draft: Bool = false) {
73 self.init(transport: { client }, connectivity: connectivity, draft: draft)
74 }
75 private func client() throws -> NativeTransport {
76 guard let current = transport() else { throw NativeClientError.unauthorized }
77 return current
78 }
79 var hasPendingInput: Bool { !editing.isEmpty || editingTask != nil }
80 var hasUnpersistedInput: Bool { metadataDirty || (renameInput != nil && renameInput != detail?.title) || composing || !editing.isEmpty || editingTask != nil || failedUpdate != nil || localFailure == .cannotSaveOnDevice }
81 var hasUnsentChanges: Bool { !laneSummary.isEmpty || pendingTitle != nil || isDraft }
82 // K6 relaxes the close guard; K3 exposes the split without bypassing it.
83 var hasUnsavedChanges: Bool { hasUnpersistedInput || hasUnsentChanges }
84 private func configurePersistence(key: String, replica: CRDTReplica) {
85 documentKey = key
86 persistence = DocsPersistence(store: offlineAccount.store, outbox: offlineAccount.outbox, key: key, replica: replica, cache: offlineAccount.replicaCache)
87 summaryTask?.cancel()
88 summaryTask = Task { [weak self, outbox = offlineAccount.outbox] in
89 for await summary in await outbox.changes {
90 guard let self, self.documentKey == key else { return }
91 self.updateLaneSummary(summary.only("doc:" + key))
92 self.reloadMetadata()
93 }
94 }
95 }
96 private func updateLaneSummary(_ summary: OutboxSummary) {
97 laneSummary = summary
98 let tooLarge = summary.lanes.values.contains { $0.bytes > 32 * 1024 * 1024 }
99 if tooLarge, localFailure == nil || localFailure == .tooManyUnsent {
100 localFailure = .tooManyUnsent; inputPaused = true
101 } else if !tooLarge, localFailure == .tooManyUnsent {
102 localFailure = nil; inputPaused = detail?.can_write != true || summary.held > 0
103 }
104 }
105 private func reloadMetadata() {
106 guard !metadataDirty, let key = documentKey, let stored = try? offlineAccount.metadata(key) else { return }
107 let attached = draftToken != nil && stored.draftToken == nil
108 detail = stored.detail; pendingTitle = stored.pendingTitle; draftToken = stored.draftToken
109 if attached, streamTask == nil {
110 streamTask = Task { [weak self] in await self?.listen(stored.detail.sqid) }
111 }
112 }
113 private func saveMetadata() throws {
114 guard let documentKey, let detail else { return }
115 metadataDirty = true
116 try offlineAccount.saveMetadata(DocsOfflineMetadata(detail: detail, pendingTitle: pendingTitle, draftToken: draftToken), key: documentKey)
117 metadataDirty = false
118 }
119 private func schedulePersistence() {
120 writerTask?.cancel()
121 writerTask = Task { [weak self] in
122 do { try await Task.sleep(for: .seconds(2)) } catch { return }
123 await self?.persistReplica()
124 }
125 }
126 func persistReplica() async {
127 await writer.run { [weak self] in
128 guard let self, let persistence = self.persistence else { return }
129 do {
130 if try await !persistence.persist() { self.schedulePersistence() }
131 else if self.localFailure == .cannotSaveOnDevice, self.failedUpdate == nil, !self.metadataDirty {
132 self.localFailure = nil
133 if self.detail?.can_write == true { self.inputPaused = false }
134 }
135 } catch { self.localFailure = .cannotSaveOnDevice; self.inputPaused = true }
136 }
137 }
138 private func durable(_ update: Data) async throws {
139 guard let persistence else { throw BlobStoreError.unavailable }
140 failedUpdate = update
141 try await persistence.enqueue(update)
142 failedUpdate = nil
143 updateLaneSummary(await offlineAccount.outbox.summary.only(persistence.lane))
144 try await persistence.compact()
145 }
146 func load(query: String = "") async {
147 do { try await offlineAccount.activate() } catch { localFailure = .cannotSaveOnDevice }
148 let request = UUID(); listRequest = request
149 listState = .loading
150 do {
151 var components = URLComponents(); components.queryItems = [URLQueryItem(name: "q", value: query)]
152 let q = (components.percentEncodedQuery ?? "").replacingOccurrences(of: "+", with: "%2B")
153 let response: DocumentList = try await client().request("/?\(q)")
154 guard listRequest == request, !Task.isCancelled else { return }
155 documents = response.documents; canCreate = response.can_create
156 listState = .loaded; errorKey = nil
157 } catch {
158 guard listRequest == request else { return }
159 listState = .failed; errorKey = "docs.error.generic"
160 }
161 }
162 /// The editor and its initial paragraph are local. Server acknowledgement
163 /// only attaches an identity; it never reconstructs the editor or selection.
164 func newDraft() async {
165 guard detail == nil, replica == nil else { return }
166 if draftToken == nil { draftToken = UUID().uuidString }
167 let epoch = documentEpoch
168 let guid = UUID().uuidString
169 let local = CRDTReplica(guid: guid)
170 loading = true
171 defer { if documentEpoch == epoch { loading = false } }
172 do {
173 try await offlineAccount.outbox.load()
174 _ = try await local.open(update: Data())
175 let (update, _) = try await local.command(["op": .string("insert_element"), "at": .number(0), "type": .string("paragraph")])
176 guard documentEpoch == epoch else { await local.close(); return }
177 replica = local
178 configurePersistence(key: "draft-" + draftToken!, replica: local)
179 detail = DocumentDetail(sqid: "draft-" + draftToken!, title: String(localized: "docs.document.untitled", bundle: DocsResources.bundle), guid: guid, can_write: true, can_share: false)
180 failedUpdate = update
181 try saveMetadata()
182 try await durable(update)
183 await publish()
184 guard documentEpoch == epoch else { return }
185 offlineAccount.drainer.start()
186 startCreation()
187 } catch { if documentEpoch == epoch { errorKey = "native.pending"; localFailure = .cannotSaveOnDevice; inputPaused = true } }
188 }
189 private func startCreation() {
190 guard creationTask == nil, draftToken != nil else { return }
191 creatingDocument = true
192 let epoch = documentEpoch
193 creationTask = Task {
194 await attachDraft(epoch: epoch)
195 if documentEpoch == epoch { creationTask = nil; creatingDocument = false }
196 }
197 }
198 private func attachDraft(epoch: UUID) async {
199 guard let key = documentKey else { return }
200 do {
201 try await offlineAccount.ensureMetadataEntry(key)
202 await offlineAccount.drainer.drain()
203 guard documentEpoch == epoch else { return }
204 reloadMetadata()
205 if isDraft { errorKey = "native.pending" }
206 else { await persistReplica(); await flush() }
207 } catch { if documentEpoch == epoch { errorKey = "native.pending" } }
208 }
209 @discardableResult func commitRename() -> Bool {
210 guard let value = renameInput else { return true }
211 guard rename(value) else { return false }
212 renameInput = nil
213 return true
214 }
215 @discardableResult func rename(_ value: String) -> Bool {
216 guard let detail, detail.can_write else { return false }
217 let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines)
218 let title = trimmed.isEmpty ? String(localized: "docs.document.untitled", bundle: DocsResources.bundle) : trimmed
219 guard title.utf8.count <= 1024 else { errorKey = "docs.error.generic"; return false }
220 if title == detail.title {
221 guard metadataDirty else { return true }
222 do { try saveMetadata() } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return false }
223 }
224 self.detail = DocumentDetail(sqid: detail.sqid, title: title, guid: detail.guid, can_write: detail.can_write, can_share: detail.can_share)
225 pendingTitle = title; titleRevision &+= 1
226 do { try saveMetadata() } catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return false }
227 Task {
228 do { if let documentKey { try await offlineAccount.ensureMetadataEntry(documentKey) } }
229 catch { localFailure = .cannotSaveOnDevice; inputPaused = true }
230 await flush()
231 }
232 return true
233 }
234 func open(_ sqid: String) async {
235 guard !hasUnsavedChanges else { errorKey = "native.pending"; return }
236 let epoch = UUID(); documentEpoch = epoch; editorIdentity = UUID()
237 streamTask?.cancel(); streamTask = nil
238 let previous = replica; replica = nil; detail = nil; projection = nil
239 loading = true; errorKey = nil
240 if let previous { await previous.close() }
241 guard documentEpoch == epoch else { return }
242 defer { if documentEpoch == epoch { loading = false } }
243 do {
244 try await offlineAccount.outbox.load()
245 let key = try offlineAccount.key(for: sqid)
246 let cached = try offlineAccount.metadata(key)
247 pendingTitle = cached?.pendingTitle; draftToken = cached?.draftToken
248 let detail: DocumentDetail
249 let initial: Data
250 let seq: Int64
251 if let cached, try offlineAccount.store.read("cache/docs/\(key)/replica") != nil {
252 detail = cached.detail; pendingTitle = cached.pendingTitle; initial = Data(); seq = 0
253 } else {
254 let remote: DocumentDetail = try await client().request("/d/\(sqid)")
255 detail = DocumentDetail(sqid: remote.sqid, title: cached?.pendingTitle ?? remote.title, guid: remote.guid, can_write: remote.can_write, can_share: remote.can_share)
256 let reply = try await client().responseDetails("/d/\(sqid)/updates", maxBytes: 8 * 1024 * 1024)
257 try reply.requireSuccess()
258 guard let cursor = Int64(reply.header("Docs-Seq") ?? "") else { throw CRDTReplica.Failure.rejected }
259 initial = reply.data; seq = cursor
260 }
261 guard documentEpoch == epoch else { return }
262 let replica = CRDTReplica(guid: detail.guid)
263 configurePersistence(key: key, replica: replica)
264 try await persistence!.reopen(fallback: initial, sequence: seq)
265 let projection = try await replica.projection()
266 guard documentEpoch == epoch else { await replica.close(); return }
267 self.replica = replica; self.detail = detail
268 reloadMetadata()
269 sequence = try await replica.persistentState().sequence
270 laneSummary = await offlineAccount.outbox.summary.only("doc:" + key)
271 if cached == nil { try saveMetadata() }
272 try await offlineAccount.ensureMetadataEntry(key)
273 projectionGeneration = 0; renderedGeneration = 0
274 selection = NSRange(location: 0, length: 0); typingMarks = nil
275 composing = false; inputPaused = false
276 updateLaneSummary(laneSummary)
277 await publish()
278 guard documentEpoch == epoch else { return }
279 if projection.children.isEmpty && detail.can_write {
280 enqueue(["op":.string("insert_element"), "at":.number(0), "type":.string("paragraph")])
281 }
282 await persistReplica()
283 offlineAccount.drainer.start()
284 streamTask = Task { [weak self] in await self?.listen(sqid) }
285 } catch { if documentEpoch == epoch { errorKey = "docs.error.generic"; detail = nil; projection = nil } }
286 }
287 @discardableResult func enqueue(_ command: [String: JSONValue]) -> Bool {
288 guard !inputPaused, detail?.can_write == true else { return false }
289 let buffered = editing.reduce(0) { $0 + ((try? JSONEncoder().encode($1).count) ?? 0) }
290 guard let incoming = try? JSONEncoder().encode(command).count, incoming <= 2 * 1024 * 1024 - buffered else { errorKey = "native.pending"; return false }
291 var command = command
292 if case .number(let generation) = command["generation"], let value = UInt64(exactly: generation) { didRender(value) }
293 if command["generation"] == nil, renderedGeneration != 0 { command["generation"] = .number(Double(renderedGeneration)) }
294 if [JSONValue.string("replace_text"), .string("display_edit")].contains(command["op"] ?? .null), let typingMarks { command["marks"] = .object(typingMarks) }
295 if ![JSONValue.string("display_edit"), .string("display_autoformat"), .string("display_revert_rule"), .string("display_exit_list"), .string("insert_element")].contains(command["op"] ?? .null) { explicitCommandRevision &+= 1 }
296 editing.append(command)
297 if editingTask == nil { editingTask = Task { await drainEdits() } }
298 return true
299 }
300 private func drainEdits() async {
301 let epoch = documentEpoch
302 defer { if documentEpoch == epoch { editingTask = nil } }
303 while documentEpoch == epoch, !editing.isEmpty, let replica {
304 let command = editing.removeFirst()
305 do {
306 let (update, _) = try await replica.command(command)
307 guard documentEpoch == epoch else { return }
308 do { try await durable(update) }
309 catch { localFailure = .cannotSaveOnDevice; inputPaused = true; break }
310 if editing.isEmpty { await publish() }
311 } catch {
312 guard documentEpoch == epoch else { return }
313 errorKey = "native.recovered"; editing.insert(command, at: 0); inputPaused = true; break
314 }
315 }
316 Task { await flush() }
317 }
318 func compositionChanged(_ active: Bool) {
319 composing = active
320 if !active && editingTask == nil { Task { await publish() } }
321 }
322 func canRender(_ generation: UInt64) -> Bool { !hasPendingInput && !composing && generation == projectionGeneration }
323 func currentSelection() -> NSRange { selection }
324 func selectionMovedExplicitly() { typingMarks = nil }
325 func didRender(_ generation: UInt64) {
326 // A delayed acknowledgement cannot retire the currently displayed source.
327 guard generation > renderedGeneration else { return }
328 renderedGeneration = generation
329 }
330 private func publish() async { await publications.run { await self.publishCurrent() } }
331 private func publishCurrent() async {
332 let epoch = documentEpoch
333 guard !composing, editing.isEmpty, let replica else { return }
334 await replica.retainGenerations(Set([renderedGeneration, projectionGeneration]))
335 guard documentEpoch == epoch, editing.isEmpty else { return }
336 do {
337 let source = renderedGeneration; let selected = selection
338 if let (generation, projected, resolved) = try await replica.presentation(source: source, selection: selected, allowReuse: source == projectionGeneration), editing.isEmpty, source == renderedGeneration, selected == selection, !composing, documentEpoch == epoch, generation >= projectionGeneration {
339 selection = resolved; projectionGeneration = generation; projection = projected
340 schedulePersistence()
341 }
342 } catch { if documentEpoch == epoch { errorKey = "native.recovered" } }
343 }
344 func retry() async {
345 let epoch = documentEpoch
346 if localFailure == .cannotSaveOnDevice || failedUpdate != nil {
347 do {
348 try await offlineAccount.outbox.load()
349 if metadataDirty { try saveMetadata() }
350 if let failedUpdate { try await durable(failedUpdate) }
351 guard documentEpoch == epoch else { return }
352 localFailure = nil; inputPaused = false
353 await persistReplica()
354 guard localFailure == nil else { return }
355 await publish()
356 } catch { return }
357 }
358 if let persistence { updateLaneSummary(await offlineAccount.outbox.summary.only(persistence.lane)) }
359 if localFailure == .tooManyUnsent { return }
360 offlineAccount.drainer.start()
361 if editingTask == nil, !editing.isEmpty, !inputPaused {
362 editingTask = Task { await drainEdits() }
363 }
364 if isDraft {
365 if replica == nil { await newDraft() } else { startCreation() }
366 await creationTask?.value
367 return
368 }
369 if transport() != nil { offlineAccount.drainer.sessionRestored() }
370 offlineAccount.drainer.retryNow()
371 if inputPaused {
372 if let detail { await refreshAccess(detail.sqid, epoch: epoch) }
373 guard documentEpoch == epoch, detail?.can_write == true else { return }
374 inputPaused = false; errorKey = nil
375 if editingTask == nil { editingTask = Task { await drainEdits() } }
376 } else { await flush() }
377 }
378 func stop() async {
379 lifetime = UUID()
380 await persistReplica()
381 guard !hasUnsavedChanges else { return }
382 documentEpoch = UUID(); streamTask?.cancel(); streamTask = nil
383 writerTask?.cancel(); summaryTask?.cancel()
384 let previous = replica; replica = nil; detail = nil; projection = nil
385 if let previous { await previous.close() }
386 }
387 /// Discard only unsent local draft state. An already acknowledged network
388 /// update remains part of the shared document, as in the web editor.
389 func discardAll() async {
390 documentEpoch = UUID()
391 writerTask?.cancel(); editingTask?.cancel(); streamTask?.cancel()
392 do {
393 try await persistence?.discard()
394 if let documentKey { try offlineAccount.store.remove("cache/docs/\(documentKey)/meta") }
395 } catch { localFailure = .cannotSaveOnDevice; return }
396 writerTask?.cancel(); summaryTask?.cancel(); persistence = nil; documentKey = nil
397 laneSummary = OutboxSummary(); failedUpdate = nil; localFailure = nil
398 documentEpoch = UUID()
399 creationTask?.cancel(); creationTask = nil; creatingDocument = false
400 draftToken = nil; pendingTitle = nil; metadataDirty = false; renameInput = nil
401 streamTask?.cancel(); streamTask = nil
402 editingTask?.cancel(); editingTask = nil
403 editing.removeAll(); saving = false
404 composing = false; inputPaused = false; typingMarks = nil
405 selection = NSRange(location: 0, length: 0)
406 let previous = replica; replica = nil; detail = nil; projection = nil
407 projectionGeneration = 0; renderedGeneration = 0; errorKey = nil
408 if let previous { await previous.close() }
409 }
410 func saveAll() async -> Bool {
411 guard commitRename() else { return false }
412 if isDraft { startCreation(); await creationTask?.value }
413 if let editingTask { await editingTask.value }
414 while saving { try? await Task.sleep(for: .milliseconds(20)) }
415 await flush()
416 await persistReplica()
417 return !hasUnsavedChanges
418 }
419 func flush() async {
420 guard !saving, detail != nil, let persistence else { return }; saving = true
421 let epoch = documentEpoch
422 defer { if documentEpoch == epoch { saving = false } }
423 do { try await offlineAccount.ensureMetadataEntry(persistence.key) }
424 catch { localFailure = .cannotSaveOnDevice; inputPaused = true; return }
425 await offlineAccount.drainer.drain()
426 guard documentEpoch == epoch else { return }
427 laneSummary = await offlineAccount.outbox.summary.only(persistence.lane)
428 reloadMetadata()
429 // SyncStatus presents a durable pending lane; it does not require
430 // keeping this document open. Preserve unrelated actionable errors.
431 if errorKey == "native.pending" || errorKey == "docs.editor.status.retrying" { errorKey = nil }
432 }
433 private func listen(_ sqid: String) async {
434 let epoch = documentEpoch
435 // Paces reconnects: a server's Retry-After is a deadline no wake
436 // crosses; otherwise back off, cut short by a connectivity change,
437 // with no timer while the path is down. The backoff resets only after
438 // a stream that stayed open for 5 s, timed from onOpen.
439 let reconnect = NativeReconnect(connectivity: connectivity, now: reconnectClock)
440 while !Task.isCancelled, documentEpoch == epoch, detail?.sqid == sqid {
441 await flush()
442 do {
443 try await client().stream("/d/\(sqid)/stream", onOpen: { await reconnect.opened() }) { [weak self] line in
444 if line.hasPrefix("data:"), let seq = Int64(line.dropFirst(5).trimmingCharacters(in: .whitespaces)) {
445 await self?.refresh(sqid, remoteSequence: seq)
446 }
447 }
448 } catch {
449 reconnect.failed(error)
450 if !Task.isCancelled, documentEpoch == epoch {
451 errorKey = "docs.editor.status.retrying"
452 await refreshAccess(sqid, epoch: epoch)
453 }
454 }
455 await reconnect.pause()
456 }
457 }
458 private func refresh(_ sqid: String, remoteSequence: Int64) async {
459 let epoch = documentEpoch
460 guard detail?.sqid == sqid, remoteSequence > sequence, let replica else { return }
461 do {
462 let reply = try await client().responseDetails("/d/\(sqid)/updates?since=\(sequence)", maxBytes: 8 * 1024 * 1024)
463 guard reply.status == 200, let seq = Int64(reply.header("Docs-Seq") ?? "") else { throw CRDTReplica.Failure.rejected }
464 guard self.detail?.sqid == sqid, self.replica === replica else { return }
465 _ = try await replica.apply(reply.data)
466 guard self.detail?.sqid == sqid, self.replica === replica else { return }
467 sequence = max(sequence, seq)
468 await replica.setSequence(sequence)
469 await persistReplica()
470 if editing.isEmpty && editingTask == nil { await publish() }
471 await flush()
472 } catch { if documentEpoch == epoch { errorKey = "docs.editor.status.retrying" } }
473 }
474 /// Revalidate capability after stream/write rejection while retaining the draft.
475 private func refreshAccess(_ sqid: String, epoch: UUID) async {
476 let revision = titleRevision
477 let hadPendingTitle = pendingTitle != nil
478 do {
479 let reply = try await client().responseDetails("/d/\(sqid)", maxBytes: 1024 * 1024)
480 guard documentEpoch == epoch else { return }
481 if reply.status == 200 {
482 let current = try JSONDecoder().decode(DocumentDetail.self, from: reply.data)
483 // The GET may have read its title before a rename acknowledged.
484 // Clearing pendingTitle must not let that old reply win afterward.
485 let title = pendingTitle ?? ((hadPendingTitle || titleRevision != revision) ? (detail?.title ?? current.title) : current.title)
486 detail = DocumentDetail(sqid: current.sqid, title: title, guid: current.guid, can_write: current.can_write, can_share: current.can_share)
487 if !current.can_write { inputPaused = true }
488 } else if [401, 403, 404].contains(reply.status) {
489 inputPaused = true
490 if let current = detail {
491 detail = DocumentDetail(sqid: current.sqid, title: current.title, guid: current.guid, can_write: false, can_share: false)
492 }
493 }
494 } catch { /* A connection failure cannot establish a capability change. */ }
495 }
496
497}
498