diff options
| author | Sacha Chua <sacha@sachachua.com> | 2023-12-11 09:56:55 -0500 | 
|---|---|---|
| committer | Sacha Chua <sacha@sachachua.com> | 2023-12-11 09:56:55 -0500 | 
| commit | 104852e78e0750074479f7a8e5ad9f4d5d247294 (patch) | |
| tree | 63f61ee19961ff9b9dfef7b71c2f0a716e167139 | |
| parent | 281b0f4a7706d2f89e93fb1678f1d76e06f2c39d (diff) | |
| download | emacsconf-el-104852e78e0750074479f7a8e5ad9f4d5d247294.tar.xz emacsconf-el-104852e78e0750074479f7a8e5ad9f4d5d247294.zip  | |
some toobnix code
| -rw-r--r-- | emacsconf-extract.el | 385 | 
1 files changed, 311 insertions, 74 deletions
diff --git a/emacsconf-extract.el b/emacsconf-extract.el index ceca898..52d181b 100644 --- a/emacsconf-extract.el +++ b/emacsconf-extract.el @@ -693,14 +693,21 @@ Would you like to help? See [[help_with_chapter_markers]] for more details. You  		(when (called-interactively-p 'any) (kill-new s))  		s)) -(defun emacsconf-extract-download-published-recordings (source dest) +(defun emacsconf-extract-download-published-recordings-command ()  	"Copy the command for downloading published recordings from SOURCE to DEST." +	(interactive)  	(kill-new  	 (mapconcat (lambda (o) (if (plist-get o :bbb-meeting-id) -															(format "rsync -avzue ssh %s%s %s\n" -																			source -																			(match-string 1 (plist-get o :bbb-rec)) -																			dest) +															(replace-regexp-in-string +															 "/ssh:" "" +															 (format "rsync -avzue ssh %s %s # %s\n" +																			 (expand-file-name +																				(plist-get o :bbb-meeting-id) +																				(expand-file-name "published/presentation" +																													emacsconf-extract-bbb-path +																													)) +																			 emacsconf-extract-bbb-published-dir +																			 (plist-get o :slug)))  														""))  							(emacsconf-get-talk-info)))) @@ -713,6 +720,23 @@ Would you like to help? See [[help_with_chapter_markers]] for more details. You  	(setq talk (emacsconf-resolve-talk talk))  	(expand-file-name "events.xml" (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-raw-dir))) +(defun emacsconf-extract-bbb-dired-raw (talk) +	(interactive (list (emacsconf-complete-talk-info))) +	(setq talk (emacsconf-resolve-talk talk)) +	(dired (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-raw-dir))) + +(defun emacsconf-extract-bbb-dired-published (talk) +	(interactive (list (emacsconf-complete-talk-info))) +	(setq talk (emacsconf-resolve-talk talk)) +	(dired (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir))) + +(defun emacsconf-extract-waveform-published-webcam-video (talk) +	(interactive (list (emacsconf-complete-talk-info))) +	(setq talk (emacsconf-resolve-talk talk)) +	(waveform-show (expand-file-name "video/webcams.webm" +																	 (expand-file-name (plist-get talk :bbb-meeting-id) emacsconf-extract-bbb-published-dir)))) + +  (defun emacsconf-extract-bbb-parse-events (talk)  	"Parse events TALK from raw recordings.  This works with the events.xml from /var/bigbluebutton/raw. @@ -957,7 +981,8 @@ Strategies:           (output (expand-file-name (concat (plist-get talk :file-prefix) "--answers.webm") emacsconf-cache-dir))           (webcam-video (expand-file-name "video/webcams.webm" source-dir))           (deskshare-video (expand-file-name "deskshare/deskshare.webm" source-dir)) -				 (stop-ms (or stop-ms (emacsconf-get-file-duration-ms webcam-video))) +				 (webcam-duration (emacsconf-get-file-duration-ms webcam-video)) +				 (stop-ms (or stop-ms webcam-duration))  				 (command  					(if (file-exists-p deskshare-video)  							;; Has deskshare @@ -971,10 +996,15 @@ Strategies:  										 (compile-media-output-video-height (cdr final-size)))  								(compile-media-get-command spans output))  						;; Just webcams -						(compile-media-get-command -						 (compile-media-split-tracks -							(list (list :source webcam-video :start-ms start-ms :stop-ms stop-ms))) -						 output)))) +						(if (and (= start-ms 0) +										 (= stop-ms webcam-duration)) +								(format "cp %s %s" +												webcam-video +												output) +							(compile-media-get-command +							 (compile-media-split-tracks +								(list (list :source webcam-video :start-ms start-ms :stop-ms stop-ms))) +							 output)))))  		(when (called-interactively-p 'any)  			(kill-new command))  		command)) @@ -1079,19 +1109,27 @@ Strategies:  					:headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")))  					:as #'json-read))  	(setq emacsconf-extract-youtube-api-videos -				(plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" -													(url-hexify-string -													 (let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0) -														 .contentDetails.relatedPlaylists.uploads) -													 )) -					:headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) -					:as #'json-read))) +				(emacsconf-extract-youtube-api-paginated-request +				 (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" +								 (url-hexify-string +									(let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0) +										.contentDetails.relatedPlaylists.uploads) +									)) +				 nil +				 (lambda (item) +					 (string-match (regexp-quote emacsconf-year) +												 (let-alist item .snippet.title))))))  (defvar emacsconf-extract-youtube-tags '("emacs" "emacsconf")) -(defun emacsconf-extract-youtube-object (video-id talk &optional privacy-status) -	"Format the video object for VIDEO-ID using TALK details." +(defun emacsconf-extract-youtube-object (video-id talk &optional privacy-status qa) +	"Format the video object for VIDEO-ID using TALK details. +If QA is non-nil, treat it as a Q&A video."  	(setq privacy-status (or privacy-status "public")) -	(let ((properties (emacsconf-publish-talk-video-properties talk 'youtube))) +	(let ((properties +				 (funcall (if qa +											#'emacsconf-publish-answers-video-properties +										#'emacsconf-publish-talk-video-properties) +									talk 'youtube)))  		`((id . ,video-id)  			(kind . "youtube#video")  			(snippet @@ -1099,9 +1137,8 @@ Strategies:  			 (title . ,(plist-get properties :title))  			 (tags . ,emacsconf-extract-youtube-tags)  			 (description . ,(plist-get properties :description)) -			 ;; Even though I set recordingDetails and status, it doesn't seem to stick. -			 ;; I'll leave this in here in case someone else can figure it out.  			 (recordingDetails (recordingDate . ,(format-time-string "%Y-%m-%dT%TZ" (plist-get talk :start-time) t)))) +			;; oooh, publishing seems to work now  			(status (privacyStatus . ,privacy-status)  							(license . "creativeCommon"))))) @@ -1129,16 +1166,53 @@ Strategies:  	(when-let ((slug (emacsconf-extract-youtube-get-slug-for-video video-object)))  		(emacsconf-resolve-talk slug))) -(defun emacsconf-extract-youtube-api-update-video (video-object) -	"Update VIDEO-OBJECT." +(defun emacsconf-extract-youtube-api-videos () +	(plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" +										(url-hexify-string +										 (let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0) +											 .contentDetails.relatedPlaylists.uploads) +										 )) +		:headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) +		:as #'json-read)) + +(defun emacsconf-extract-youtube-api-paginated-request (url &optional num-pages condition) +	(let (result current-page (base-url url) current-page-items) +		(while url +			(setq current-page +						(plz 'get url +							:headers +							`(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) +							:as #'json-read)) +			(setq current-page-items +						(if condition +								(seq-filter condition (assoc-default 'items current-page)) +							(assoc-default 'items current-page))) +			(setq result (append result current-page-items nil)) +			(let-alist current-page +				(setq url +							(if .nextPageToken +									(concat base-url +													(if (string-match "\\?" base-url) "&" "?") +													"pageToken=" .nextPageToken) +								nil))) +			(when (= (length current-page-items) 0) (setq url nil)) +			(when num-pages +				(setq num-pages (1- num-pages)) +				(if (<= num-pages 0) (setq url null)))) +			result)) + +(defun emacsconf-extract-youtube-api-update-video (video-object &optional qa) +	"Update VIDEO-OBJECT. +If QA is non-nil, treat it as a Q&A video."  	(let-alist video-object -		(let* ((slug (or (emacsconf-extract-youtube-get-slug-for-video video-object) -										 (when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year)) -																				 .snippet.title) -											 (completing-read (format "Slug for %s: " -																								.snippet.title) -																				(seq-map (lambda (o) (plist-get o :slug)) -																								 (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info))))))) +		(let* ((slug (or +									(emacsconf-extract-youtube-get-slug-for-video video-object) +									(when (string-match (rx (literal emacsconf-id) " " (literal emacsconf-year)) +																			.snippet.title) +										(completing-read (format "Slug for %s: " +																						 .snippet.title) +																		 (seq-map (lambda (o) (plist-get o :slug)) +																							(emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))))))  					 (talk (and slug (emacsconf-resolve-talk slug)))  					 (video-id .snippet.resourceId.videoId)  					 (id .id) @@ -1146,13 +1220,13 @@ Strategies:  			(when slug  				;; set the YOUTUBE_URL property  				(emacsconf-with-talk-heading slug -					(org-entry-put (point) "YOUTUBE_URL" (concat "https://www.youtube.com/watch?v=" video-id)) -					(org-entry-put (point) "YOUTUBE_ID" id)) +					(org-entry-put (point) (if qa "QA_YOUTUBE_URL" "YOUTUBE_URL") (concat "https://www.youtube.com/watch?v=" video-id)) +					(org-entry-put (point) (if qa "QA_YOUTUBE_ID" "YOUTUBE_ID") id))  				(plz 'put "https://www.googleapis.com/youtube/v3/videos?part=snippet,recordingDetails,status"  					:headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))  										 ("Accept" . "application/json")  										 ("Content-Type" . "application/json")) -					:body (json-encode (emacsconf-extract-youtube-object video-id talk))))))) +					:body (json-encode (emacsconf-extract-youtube-object video-id talk nil qa)))))))  (defun emacsconf-extract-youtube-rename-videos (&optional videos)  	"Rename videos and set the YOUTUBE_URL property in the Org heading." @@ -1167,53 +1241,216 @@ Strategies:  		 (assoc-default  			'items  			(or videos emacsconf-extract-youtube-api-videos -					(plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" -														(url-hexify-string -														 (let-alist (elt (assoc-default 'items emacsconf-extract-youtube-api-channels) 0) -															 .contentDetails.relatedPlaylists.uploads) -														 )) -						:headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) -						:as #'json-read)))))) +					(emacsconf-extract-youtube-api-videos)))))) + +(defun emacsconf-extract-youtube-rename-draft-videos-as-qa (&optional videos) +	"Rename videos and set the QA_YOUTUBE_URL property in the Org heading." +	(interactive) +	(let ((info (emacsconf-get-talk-info))) +		(mapc +		 (lambda (video) +			 (let-alist video +				 (when (and +								(string= .status.privacyStatus "private") +								(string-match (rx (literal emacsconf-id) " " (literal emacsconf-year) " ") +															.snippet.title)) +					 (emacsconf-extract-youtube-api-update-video video t)))) +		 (assoc-default +			'items +			(or videos emacsconf-extract-youtube-api-videos +					(emacsconf-extract-youtube-api-videos))))))  ;; This still needed some tweaking, so maybe next time we'll try just inserting the items into the playlist +(defvar emacsconf-extract-youtube-api-playlist nil) +(defvar emacsconf-extract-youtube-api-playlist-items nil)  (defun emacsconf-extract-youtube-sort-playlist ()  	(interactive) -	(let* ((playlist-info -					(seq-find (lambda (o) (let-alist o (string= .snippet.title (concat emacsconf-name " " emacsconf-year)))) -										(assoc-default 'items emacsconf-extract-youtube-api-playlists))) +	(setq emacsconf-extract-youtube-api-playlist (seq-find (lambda (o) (let-alist o (string= .snippet.title (concat emacsconf-name " " emacsconf-year)))) +																				(assoc-default 'items emacsconf-extract-youtube-api-playlists))) +	(setq emacsconf-extract-youtube-api-playlist-items +				(emacsconf-extract-youtube-api-paginated-request (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=100&playlistId=" +																								(url-hexify-string (assoc-default 'id emacsconf-extract-youtube-api-playlist))))) +	(let* ((playlist-info emacsconf-extract-youtube-api-playlists)  				 (playlist-items -					(assoc-default 'items -												 (plz 'get (concat "https://youtube.googleapis.com/youtube/v3/playlistItems?part=snippet,contentDetails,status&forMine=true&order=date&maxResults=50&playlistId=" (url-hexify-string (assoc-default 'id playlist-info))) -													 :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/"))) -													 :as #'json-read))) +					(assoc-default 'items emacsconf-youtube-api-playlist-items))  				 (info (emacsconf-publish-prepare-for-display (emacsconf-get-talk-info)))  				 (slugs (seq-map (lambda (o) (plist-get o :slug)) info)) -				 last-position) +				 (position (1- (length playlist-items))))  		;; sort items -		(seq-map-indexed (lambda (talk position) -											 (let ((video-object (seq-find (lambda (video) (string= (plist-get talk :slug) -																																							(emacsconf-extract-youtube-get-slug-for-video video))) -																										 playlist-items))) -												 (let-alist video-object -													 (cond -														((null video-object) -														 (message "Could not find video for %s" (plist-get talk :slug))) -														;; not in the right position, try to move it -														((> .snippet.position position) -														 (let ((video-id .id) -																	 (playlist-id .snippet.playlistId) -																	 (resource-id .snippet.resourceId)) -															 (message "Trying to move %s to %d from %d" (plist-get talk :slug) position .snippet.position) -															 (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet" -																 :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) -																						("Accept" . "application/json") -																						("Content-Type" . "application/json")) -																 :body (json-encode -																				`((id . ,video-id) -																					(snippet -																					 (playlistId . ,playlist-id) -																					 (resourceId . ,resource-id) -																					 (position . ,position))))))))) -												 )) info))) +		(seq-map (lambda (talk) +							 (when (plist-get talk :qa-youtube-id) +								 ;; move the q & a +								 (let ((video-object (seq-find (lambda (video) (string= (assoc-default 'id video) +																																				(plist-get talk :qa-youtube-id))) +																							 playlist-items))) +									 (let-alist video-object +										 (cond +											((null video-object) +											 (message "Could not find video for %s" (plist-get talk :slug))) +											;; not in the right position, try to move it +											((< .snippet.position position) +											 (let ((video-id .id) +														 (playlist-id .snippet.playlistId) +														 (resource-id .snippet.resourceId)) +												 (message "Trying to move %s Q&A to %d from %d" (plist-get talk :slug) position .snippet.position) +												 (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet" +													 :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) +																			("Accept" . "application/json") +																			("Content-Type" . "application/json")) +													 :body (json-encode +																	`((id . ,video-id) +																		(snippet +																		 (playlistId . ,playlist-id) +																		 (resourceId . ,resource-id) +																		 (position . ,position))))))))) +									 (setq position (1- position))) +								 ;; move the talk if needed +								 (let ((video-object (seq-find (lambda (video) (string-match +																																(regexp-quote (let-alist video .resourceId.videoId)) +																																(plist-get talk :youtube-url))) +																							 playlist-items))) +									 (let-alist video-object +										 (cond +											((null video-object) +											 (message "Could not find video for %s" (plist-get talk :slug))) +											;; not in the right position, try to move it +											((< .snippet.position position) +											 (let ((video-id .id) +														 (playlist-id .snippet.playlistId) +														 (resource-id .snippet.resourceId)) +												 (message "Trying to move %s to %d from %d" (plist-get talk :slug) position .snippet.position) +												 (plz 'put "https://www.googleapis.com/youtube/v3/playlistItems?part=snippet" +													 :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) +																			("Accept" . "application/json") +																			("Content-Type" . "application/json")) +													 :body (json-encode +																	`((id . ,video-id) +																		(snippet +																		 (playlistId . ,playlist-id) +																		 (resourceId . ,resource-id) +																		 (position . ,position))))))))) +									 (setq position (1- position))))) +						 (nreverse info)))) + +(defun emacsconf-extract-youtube-get-video-details (&optional videos) +	(let (result url) +		(dolist (partition (seq-partition (or videos emacsconf-extract-youtube-api-videos) 50) result) +			(setq url + 						(format "https://www.googleapis.com/youtube/v3/videos?id=%s&part=snippet,contentDetails,statistics" +										(mapconcat (lambda (item) (url-hexify-string (let-alist item .contentDetails.videoId))) partition ","))) +			(message "%s" url) +			(setq result +						(append result +										(assoc-default +										 'items + +										 (plz 'get +											 url +											 :headers `(("Authorization" . ,(url-oauth-auth "https://youtube.googleapis.com/youtube/v3/")) +																	("Accept" . "application/json") +																	("Content-Type" . "application/json")) +											 :as #'json-read)) +										nil))))) + +(defun emacsconf-extract-youtube-duration-msecs (video) +	(let-alist video +		(when-let ((duration .contentDetails.duration)) +			(when (string-match "PT\\(?:\\([0-9]+\\)H\\)?\\([0-9]+\\)M\\([0-9]+\\)" duration) +				(+ (* (string-to-number (or (match-string 1 duration) "0")) 60 60 1000) +					 (* (string-to-number (or (match-string 2 duration) "0")) 60 1000) +					 (* (string-to-number (or (match-string 3 duration) "0")) 1000)))))) + +(defun emacsconf-extract-youtube-format-duration (s) +	"Converts a string of the form PT1H9M22S to 1:09:22." +	(when (and s (string-match "PT\\(?:\\([0-9]+\\)H\\)?\\([0-9]+\\)M\\([0-9]+\\)" s)) +		(concat +		 (emacsconf-surround "" (match-string 1 s) ":") +		 (if (match-string 1 s) +				 (format "%02d:" (string-to-number (match-string 2 s))) +			 (concat (match-string 2 s) ":")) +		 (format "%02d" (string-to-number (match-string 3 s)))))) + +(defun emacsconf-extract-youtube-find-url-video-in-list (url &optional videos) +	(seq-find +	 (lambda (o) +		 (let-alist o +			 (or (string-match .id url) +					 (and .contentDetails.videoId (string-match .contentDetails.videoId url))))) +	 (or videos emacsconf-extract-youtube-api-videos))) + +(ert-deftest emacsconf-extract-youtube-format-duration () +	(expect (emacsconf-extract-youtube-format-duration "PT1H9M22S") :to-equal "1:09:22") +	(expect (emacsconf-extract-youtube-format-duration "PT9M22S") :to-equal "9:22")) + + +;; (setq emacsconf-extract-youtube-api-video-details (emacsconf-extract-youtube-get-video-details emacsconf-extract-youtube-api-playlist-items)) + +;;; PeerTube + +(defvar emacsconf-extract-toobnix-api-client nil) +(defvar emacsconf-extract-toobnix-api-bearer-token nil) +(defvar emacsconf-extract-toobnix-api-username "bandali") +(defvar emacsconf-extract-toobnix-api-channel-handle "emacsconf") + +(defun emacsconf-extract-toobnix-api-header () +	`(("Authorization" . ,(concat "Bearer " emacsconf-extract-toobnix-api-bearer-token)))) + +(defun emacsconf-extract-toobnix-api-setup () +	(interactive) +	(require 'plz) +	(require 'url-http-oauth) +	(setq emacsconf-extract-toobnix-api-client +				(plz 'get "https://toobnix.org/api/v1/oauth-clients/local" :as #'json-read)) +	(setq emacsconf-extract-toobnix-api-bearer-token +				(plz 'post "https://toobnix.org/api/v1/users/token" +					:body (mm-url-encode-www-form-urlencoded +								 `(("client_id" . ,(assoc-default 'client_id emacsconf-extract-toobnix-api-client)) +									 ("client_secret" . ,(assoc-default 'client_secret emacsconf-extract-toobnix-api-client)) +									 ("grant_type" . "password") +									 ("username" . ,emacsconf-extract-toobnix-api-username) +									 ("password" . ,(auth-info-password (car (auth-source-search :host "https://toobnix.org")))))) +					:as #'json-read)) +	(setq emacsconf-extract-toobnix-api-channels +				(plz 'get (format "https://toobnix.org/api/v1/accounts/%s/video-channels" +													emacsconf-extract-toobnix-api-username) +					:headers +					:as #'json-read)) +	(setq emacsconf-extract-toobnix-api-videos +				(plz 'get +					(format "https://toobnix.org/api/v1/accounts/%s/videos?count=100&sort=-createdAt" +									emacsconf-extract-toobnix-api-username) +					:headers (emacsconf-extract-toobnix-api-header) +					:as #'json-read))) + +(defun emacsconf-extract-toobnix-publish-video-from-edit-page () +	"Messy hack to set a video to public and store the URL." +	(interactive) +	(spookfox-js-injection-eval-in-active-tab "document.querySelector('label[for=privacy]').scrollIntoView(); document.querySelector('label[for=privacy]').closest('.form-group').querySelector('input').dispatchEvent(new Event('input'));" t) +	(sit-for 1) +	(spookfox-js-injection-eval-in-active-tab "document.querySelector('span[title=\"Anyone can see this video\"]').click()" t) +	(sit-for 1) +	(spookfox-js-injection-eval-in-active-tab "document.querySelector('button.orange-button').click()" t)(sit-for 3) +	(let ((desc (spookfox-js-injection-eval-in-active-tab +							 "document.querySelector('.video-info-description-html').innerHTML" t))) +		(when (string-match (rx (literal emacsconf-base-url) (literal emacsconf-year) "/talks/" +														(group (1+ (not (or " " "/" "\""))))) +												desc) +			(let ((slug (match-string 1 desc)) +						(url (spookfox-js-injection-eval-in-active-tab "window.location.href" t))) +				(save-window-excursion +					(emacsconf-with-talk-heading slug +						(org-entry-put (point) +													 (if (string-match "Q&A" desc) +															 "QA_TOOBNIX_URL" +														 "TOOBNIX_URL") +													 url) +						(message "Updating %s %s %s" +										 slug +										 (if (string-match "Q&A" desc) +												 "QA_TOOBNIX_URL" +											 "TOOBNIX_URL") +										 url)))) +			(shell-command "xdotool key Alt+Tab sleep 1 key Ctrl+w Alt+Tab")))) +  (provide 'emacsconf-extract)  ;;; emacsconf-extract.el ends here  | 
