Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
62 changes: 62 additions & 0 deletions gdocs-api.el
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,49 @@

;;;; Public API — Google Docs

(defvar gdocs-api-debug-batch-update-file nil
"File where the last Docs batchUpdate payload is written for debugging.
When nil, the latest batchUpdate payload is not recorded.")

(defvar gdocs-api-debug-batch-update-history-directory nil
"Directory where all Docs batchUpdate payloads are written for debugging.
When nil, batchUpdate payload history is not recorded.")

(defvar gdocs-api--debug-batch-update-sequence 0
"Monotonic sequence number for debug batchUpdate history files.")

(defun gdocs-api--batch-update-debug-payload (document-id requests)
"Return debug payload for DOCUMENT-ID and REQUESTS."
`((documentId . ,document-id)
(requestCount . ,(length requests))
(requestKinds . ,(vconcat
(mapcar (lambda (request)
(symbol-name (caar request)))
requests)))
(requests . ,requests)))

(defun gdocs-api--record-batch-update-history (payload)
"Record batchUpdate PAYLOAD in the history directory."
(when gdocs-api-debug-batch-update-history-directory
(make-directory gdocs-api-debug-batch-update-history-directory t)
(setq gdocs-api--debug-batch-update-sequence
(1+ gdocs-api--debug-batch-update-sequence))
(let ((file (expand-file-name
(format "%s-%03d.json"
(format-time-string "%Y%m%dT%H%M%S%3N")
gdocs-api--debug-batch-update-sequence)
gdocs-api-debug-batch-update-history-directory)))
(with-temp-file file
(insert (json-encode payload))))))

(defun gdocs-api--record-batch-update (document-id requests)
"Record DOCUMENT-ID and REQUESTS for post-mortem debugging."
(let ((payload (gdocs-api--batch-update-debug-payload document-id requests)))
(when gdocs-api-debug-batch-update-file
(with-temp-file gdocs-api-debug-batch-update-file
(insert (json-encode payload))))
(gdocs-api--record-batch-update-history payload)))

(defun gdocs-api-get-document (document-id callback &optional account on-error)
"Fetch a Google Docs document by DOCUMENT-ID.
CALLBACK is called with the parsed JSON document structure.
Expand All @@ -60,13 +103,32 @@ condition instead of signaling."
:account account
:on-error on-error))

(defun gdocs-api--empty-delete-content-range-p (request)
"Return non-nil when REQUEST has an empty deleteContentRange."
(when-let* ((delete (alist-get 'deleteContentRange request))
(range (alist-get 'range delete))
(start (alist-get 'startIndex range))
(end (alist-get 'endIndex range)))
(<= end start)))

(defun gdocs-api--validate-batch-requests (requests)
"Fail locally when REQUESTS contain invalid no-op mutations."
(let ((index 0))
(dolist (request requests)
(when (gdocs-api--empty-delete-content-range-p request)
(error "Invalid local Google Docs request[%d].deleteContentRange: %S"
index request))
(setq index (1+ index)))))

(defun gdocs-api-batch-update (document-id requests callback
&optional account on-error)
"Send a batchUpdate to DOCUMENT-ID with REQUESTS.
REQUESTS is a list of request alists. CALLBACK is called with
the parsed JSON response. ACCOUNT is an optional account name.
ON-ERROR, if non-nil, is called with the error condition instead
of signaling; use this to clean up async state on failure."
(gdocs-api--record-batch-update document-id requests)
(gdocs-api--validate-batch-requests requests)
(gdocs-api--request 'post
(concat gdocs-api--docs-base-url
"/" document-id ":batchUpdate")
Expand Down
54 changes: 54 additions & 0 deletions test/gdocs-api-test.el
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,60 @@
(require 'cl-lib)
(require 'plz)

;;;; Request validation

(ert-deftest gdocs-api-test-record-batch-update-writes-debug-payload ()
"Batch update recording writes the last request payload for debugging."
(let ((gdocs-api-debug-batch-update-file
(make-temp-file "gdocs-batch-debug" nil ".json"))
(gdocs-api-debug-batch-update-history-directory nil))
(unwind-protect
(progn
(gdocs-api--record-batch-update
"doc-1" '(((insertText . ((text . "x")
(location . ((index . 1))))))))
(let ((payload (with-temp-buffer
(insert-file-contents gdocs-api-debug-batch-update-file)
(json-read-from-string (buffer-string)))))
(should (equal (alist-get 'documentId payload) "doc-1"))
(should (= (alist-get 'requestCount payload) 1))
(should (equal (append (alist-get 'requestKinds payload) nil)
'("insertText")))))
(delete-file gdocs-api-debug-batch-update-file))))

(ert-deftest gdocs-api-test-record-batch-update-writes-history-payloads ()
"Batch update recording keeps all payloads in a history directory."
(let ((gdocs-api-debug-batch-update-file nil)
(gdocs-api-debug-batch-update-history-directory
(make-temp-file "gdocs-batch-history" t))
(gdocs-api--debug-batch-update-sequence 0))
(unwind-protect
(progn
(gdocs-api--record-batch-update
"doc-1" '(((insertText . ((text . "x")
(location . ((index . 1))))))))
(gdocs-api--record-batch-update
"doc-1" '(((deleteContentRange
. ((range . ((startIndex . 1) (endIndex . 2))))))))
(should (= 2 (length (directory-files
gdocs-api-debug-batch-update-history-directory
nil "\\.json\\'")))))
(delete-directory gdocs-api-debug-batch-update-history-directory t))))

(ert-deftest gdocs-api-test-validate-batch-rejects-empty-delete ()
"Batch request validation fails before sending empty deletes to Google."
(should-error
(gdocs-api--validate-batch-requests
'(((deleteContentRange
. ((range . ((startIndex . 4) (endIndex . 4))))))))))

(ert-deftest gdocs-api-test-validate-batch-allows-non-empty-delete ()
"Batch request validation accepts non-empty deletes."
(should-not
(gdocs-api--validate-batch-requests
'(((deleteContentRange
. ((range . ((startIndex . 4) (endIndex . 5))))))))))

;;;; Header building

(ert-deftest gdocs-api-test-build-headers-get-request ()
Expand Down