export.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546
  1. #!/usr/bin/env python3
  2. import datetime
  3. import mimetypes
  4. import pathlib
  5. from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView
  6. import click
  7. import frontmatter
  8. import gkeepapi
  9. from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
  10. from mdutils.mdutils import MdUtils
  11. from pathvalidate import sanitize_filename
  12. mimetypes.add_type("audio/3gpp", ".3gp")
  13. def login(user_email: str, password: str) -> gkeepapi.Keep:
  14. keep = gkeepapi.Keep()
  15. try:
  16. keep.login(user_email, password)
  17. except gkeepapi.exception.LoginException as ex:
  18. raise click.BadParameter(f"Login failed: {str(ex)}")
  19. return keep
  20. def all_note_media(
  21. note: gkeepapi._node.Note,
  22. ) -> List[Union[NodeImage, NodeDrawing, NodeAudio]]:
  23. """
  24. Returns a filtered list of only "media" blobs associate with the note.
  25. Currently NodeDrawing, NodeImage, and NodeMedia.
  26. There are other blob types, but they don't seem actionable as media.
  27. """
  28. return note.images + note.drawings + note.audio
  29. def download_media(
  30. keep: gkeepapi.Keep,
  31. note: gkeepapi._node.Note,
  32. mediapath: pathlib.Path,
  33. skip_existing: bool,
  34. ) -> Tuple[List[pathlib.Path], int]:
  35. note_media = all_note_media(note)
  36. if not note_media:
  37. return ([], 0)
  38. ret = []
  39. downloaded_media = 0
  40. for media in note_media:
  41. meta = media.blob.save()
  42. # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
  43. if meta.get("type", "") == "DRAWING":
  44. extension = mimetypes.guess_extension(
  45. meta.get("drawingInfo", {})
  46. .get("snapshotData", {})
  47. .get("mimetype", "image/png")
  48. ) # All drawings seem to be pngs
  49. elif meta.get("type") == "IMAGE":
  50. extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
  51. # .jpe just feels weird, but it's my default in testing
  52. if extension == ".jpe":
  53. extension = ".jpg"
  54. else: # 'AUDIO'
  55. extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
  56. # nest media files under folders named by the note's ID
  57. # this simplifies figuring out the note media files came from
  58. note_media_path = mediapath / note.id
  59. note_media_path.mkdir(exist_ok=True)
  60. media_file = (
  61. note_media_path / f"{sanitize_filename(media.id,max_len=135)}{extension}"
  62. )
  63. # checking size isn't perfect, and drawings don't have a size,
  64. # but it doesn't seem right to always re-download images that likely
  65. # haven't changed
  66. if (
  67. skip_existing
  68. and media_file.exists()
  69. and hasattr(media.blob, "byte_size")
  70. and media_file.stat().st_size == media.blob.byte_size
  71. ):
  72. click.echo(
  73. f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
  74. )
  75. else:
  76. print(f"Downloading media {meta.get('type')} {media.id} for note {note.id}")
  77. url = keep._media_api.get(media)
  78. media_data = keep._media_api._session.get(url)
  79. with media_file.open("wb") as f:
  80. f.write(media_data.content)
  81. downloaded_media += 1
  82. ret.append(media_file)
  83. return (ret, downloaded_media)
  84. def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
  85. metadata = {
  86. "google_keep_id": note.id,
  87. "title": note.title,
  88. "pinned": note.pinned,
  89. "trashed": note.trashed,
  90. "deleted": note.deleted,
  91. "color": note.color.name,
  92. "type": note.type.name,
  93. "parent_id": note.parent_id,
  94. "sort": note.sort,
  95. "url": note.url,
  96. "tags": [label.name for label in note.labels.all()],
  97. "timestamps": {
  98. "created": note.timestamps.created.timestamp(),
  99. "edited": note.timestamps.edited.timestamp(),
  100. "updated": note.timestamps.updated.timestamp(),
  101. },
  102. }
  103. # gkeepapi appears to be treating "0" as a timestamp rather than null. Sometimes the data structure does not have the key at all instead of 0.
  104. if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
  105. metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
  106. if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
  107. metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
  108. return frontmatter.Post(markdown, handler=None, **metadata)
  109. def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
  110. doc = MdUtils(
  111. ""
  112. ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
  113. doc.new_header(1, note.title)
  114. doc.new_header(2, "Note")
  115. text = note.text
  116. text = text.replace("☑ ", "- [X] ")
  117. text = text.replace("☐ ", "- [ ] ")
  118. doc.new_paragraph(text)
  119. if note.annotations.links:
  120. doc.new_line()
  121. doc.new_line()
  122. doc.new_header(2, "Links")
  123. doc.new_list(
  124. [
  125. doc.new_inline_link(link=link.url, text=link.title)
  126. for link in note.annotations.links
  127. ]
  128. )
  129. if images:
  130. doc.new_line()
  131. doc.new_header(2, "Attached Media")
  132. for image in images:
  133. doc.new_line(doc.new_inline_image("", image.name))
  134. return doc.file_data_text
  135. LocalMedia = NamedTuple(
  136. "LocalMedia",
  137. [
  138. ("path", pathlib.Path),
  139. ("google_keep_note_id", str),
  140. ("google_keep_media_id", str),
  141. ],
  142. )
  143. class LocalNote:
  144. def __init__(
  145. self,
  146. google_keep_id: str,
  147. path: Optional[pathlib.Path] = None,
  148. timestamp_updated: Optional[datetime.datetime] = None,
  149. local_media: Dict[str, LocalMedia] = None,
  150. ):
  151. self.google_keep_id = google_keep_id
  152. self.path = path
  153. self.timestamp_updated = timestamp_updated
  154. if not local_media:
  155. self.local_media: Dict[str, LocalMedia] = {}
  156. else:
  157. self.local_media = local_media
  158. def index_existing_files(directory: pathlib.Path) -> Dict[str, LocalNote]:
  159. """
  160. Scans the output folder looking for existing markdown files
  161. and media files and builds an index by google_keep_id of those files
  162. using the metadata in the markdown frontmatter and the filenames
  163. of the media files.
  164. """
  165. index: Dict[str, LocalNote] = {}
  166. keep_notes = 0
  167. unknown_notes = 0
  168. errors = 0
  169. media = 0
  170. for file in directory.rglob("*"):
  171. if not file.is_file():
  172. continue
  173. # markdown file
  174. if file.name.endswith(".md"):
  175. try:
  176. with open(file, "r") as f:
  177. fm = frontmatter.load(f)
  178. google_keep_id: str = fm.metadata.get("google_keep_id")
  179. if google_keep_id:
  180. if google_keep_id in index and index[google_keep_id].path:
  181. click.echo(
  182. f"Same Google Keep ID {google_keep_id} in multiple files:\n"
  183. f" {file}\n"
  184. f" {index[google_keep_id].path}\n"
  185. f"Only the last file will be updated."
  186. )
  187. keep_notes += 1
  188. index.setdefault(google_keep_id, LocalNote(google_keep_id))
  189. updated: datetime.datetime = datetime.datetime.fromtimestamp(
  190. fm.metadata.get("timestamps", {}).get("updated")
  191. )
  192. index[google_keep_id].timestamp_updated = updated
  193. index[google_keep_id].path = file
  194. else:
  195. unknown_notes += 1
  196. except IOError as ex:
  197. errors = 0
  198. click.echo(
  199. "Unable to open Markdown file [{os.path.join(root, file)}]. Skipping: {str(ex)}",
  200. err=True,
  201. )
  202. # media file
  203. else:
  204. media += 1
  205. google_keep_id = file.parent.name
  206. media_id = ".".join(file.name.split(".")[0:2])
  207. index.setdefault(google_keep_id, LocalNote(google_keep_id))
  208. index[google_keep_id].local_media[media_id] = LocalMedia(
  209. file, google_keep_id, media_id
  210. )
  211. click.echo(
  212. f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
  213. )
  214. return index
  215. def try_rename_note(note: LocalNote, target_file: pathlib.Path) -> pathlib.Path:
  216. """
  217. Attempts to rename an existing note to the new canonical filename,
  218. but accepts failures to rename. Returns the path the note now exists
  219. in, either the old path or the new renamed path.
  220. """
  221. if not note.path:
  222. return target_file
  223. click.echo(f"Renaming [{note.path}] to [{target_file}]")
  224. try:
  225. note.path.rename(target_file)
  226. return target_file
  227. except Exception as ex:
  228. click.echo(f"Unable to rename note. Using existing name: %s" % ex, err=True)
  229. return note.path
  230. def build_note_unique_path(
  231. notepath: pathlib.Path,
  232. note: gkeepapi._node.Note,
  233. date_format: str,
  234. local_index: Dict[str, LocalNote],
  235. ) -> pathlib.Path:
  236. title = note.title.strip()
  237. if not len(title):
  238. title = "untitled"
  239. date_str = note.timestamps.created.strftime(date_format)
  240. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
  241. target_path = notepath / filename
  242. local_note = local_index.get(note.id)
  243. local_path = local_note.path if local_note else None
  244. if local_path:
  245. # if the note filename matches the current filename for that note, then we're good
  246. if local_path == target_path:
  247. return local_path
  248. # if re-naming would result in having to de-dupe the target filename, keep the
  249. # exising filename - initial pass at fixing this just resulted in bouncing between
  250. # two different filenames each pass
  251. if target_path.exists():
  252. click.echo(
  253. f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
  254. )
  255. return local_path
  256. # otherwise, if the file already exists avoid overwriting it
  257. # put the unique note ID and an incrementing index at the end of the filename
  258. dedupe_index = 1
  259. while target_path.exists():
  260. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.{note.id}.{dedupe_index}.md'
  261. target_path = notepath / filename
  262. dedupe_index += 1
  263. return target_path
  264. def delete_local_only_files(
  265. local_index: Dict[str, LocalNote],
  266. keep_notes: Dict[str, List[gkeepapi.node.Note]],
  267. delete_local: bool,
  268. ) -> Tuple[int, int]:
  269. """
  270. Checks the local index for any notes or media that exist only locally
  271. and were not returned in the Google Keep API call.
  272. """
  273. deleted_notes, deleted_media = 0, 0
  274. local_only_note_ids = set(local_index.keys()).difference(set(keep_notes.keys()))
  275. if local_only_note_ids:
  276. if not delete_local:
  277. click.echo(
  278. f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
  279. )
  280. else:
  281. click.echo(
  282. f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Trashing local files."
  283. )
  284. for note_id in local_only_note_ids:
  285. note_path = local_index[note_id].path
  286. deleted_notes += 1
  287. if note_path:
  288. click.echo(
  289. f" Deleting unknown local note [{note_id}] file [{local_index[note_id].path}]"
  290. )
  291. note_path.unlink()
  292. local_only_media: Set[Tuple[str, str]] = set(
  293. [
  294. (local_media.google_keep_note_id, local_media.google_keep_media_id)
  295. for local_note in local_index.values()
  296. for local_media in local_note.local_media.values()
  297. if local_media.google_keep_note_id and local_media.google_keep_media_id
  298. ]
  299. )
  300. notes: ValuesView[gkeepapi._node.Note] = keep_notes.values()
  301. keep_media: Set[Tuple[str, str]] = set(
  302. [
  303. (keep_note.id, keep_media.id)
  304. for keep_note in notes
  305. for keep_media in all_note_media(keep_note)
  306. ]
  307. )
  308. local_only_media_ids = local_only_media.difference(keep_media)
  309. if not local_only_media_ids:
  310. return (deleted_notes, 0)
  311. if not delete_local:
  312. click.echo(
  313. f"{len(local_only_note_ids)} media files exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
  314. )
  315. return (deleted_notes, 0)
  316. for (note_id, media_id) in local_only_media_ids:
  317. media = local_index[note_id].local_media[media_id]
  318. click.echo(
  319. f" Deleting media [{media_id}] for note [{note_id}] file [{media.path}]"
  320. )
  321. media.path.unlink()
  322. deleted_media += 1
  323. return (deleted_notes, deleted_media)
  324. @click.command(
  325. context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
  326. )
  327. @click.option(
  328. "--user",
  329. "-u",
  330. prompt=True,
  331. required=True,
  332. envvar="GKEEP_USER",
  333. show_envvar=True,
  334. help="Google account email (prompt if empty)",
  335. )
  336. @click.option(
  337. "--password",
  338. "-p",
  339. prompt=True,
  340. required=True,
  341. envvar="GKEEP_PASSWORD",
  342. show_envvar=True,
  343. help="Google account password (prompt if empty)",
  344. hide_input=True,
  345. )
  346. @click.option(
  347. "--directory",
  348. "-d",
  349. default="./gkeep-export",
  350. show_default=True,
  351. help="Output directory for exported notes",
  352. type=click.Path(file_okay=False, dir_okay=True, writable=True),
  353. )
  354. @click.option(
  355. "--header/--no-header",
  356. default=True,
  357. show_default=True,
  358. help="Choose to include or exclude the frontmatter header",
  359. )
  360. @click.option(
  361. "--delete-local/--no-delete-local",
  362. default=False,
  363. show_default=True,
  364. help="Choose to delete or leave as-is any notes that exist locally but not in Google Keep",
  365. )
  366. @click.option(
  367. "--rename-local/--no-rename-local",
  368. default=False,
  369. show_default=True,
  370. help="Choose to rename or leave as-is any notes that change titles in Google Keep",
  371. )
  372. @click.option(
  373. "--date-format",
  374. default="%Y-%m-%d",
  375. show_default=True,
  376. help="Date format to use for the prefix of the note filenames. Reflects the created date of the note.",
  377. )
  378. @click.option(
  379. "--skip-existing-media/--no-skip-existing-media",
  380. default=True,
  381. show_default=True,
  382. help="Skip existing media if it appears unchanged from the local copy.",
  383. )
  384. def main(
  385. directory: str,
  386. user: str,
  387. password: str,
  388. header: bool,
  389. delete_local: bool,
  390. rename_local: bool,
  391. date_format: str,
  392. skip_existing_media: bool,
  393. ):
  394. """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
  395. notepath = pathlib.Path(directory).resolve()
  396. mediapath = notepath.joinpath("media/")
  397. click.echo(f"Notes directory: {notepath}")
  398. click.echo(f"Media directory: {mediapath}")
  399. click.echo("Logging in.")
  400. keep = login(user, password)
  401. if not notepath.exists():
  402. click.echo("Notes directory does not exist, creating.")
  403. notepath.mkdir(parents=True)
  404. if not mediapath.exists():
  405. click.echo("Media directory does not exist, creating.")
  406. mediapath.mkdir(parents=True)
  407. click.echo("Indexing local files.")
  408. local_index = index_existing_files(notepath)
  409. click.echo("Indexing remote notes.")
  410. keep_notes = dict([(note.id, note) for note in keep.all()])
  411. skipped_notes, updated_notes, new_notes = 0, 0, 0
  412. downloaded_media = 0
  413. deleted_notes, deleted_media = delete_local_only_files(
  414. local_index, keep_notes, delete_local
  415. )
  416. for note in keep_notes.values(): # type: gkeepapi._node.Note
  417. local_note = local_index.get(note.id)
  418. if not local_note:
  419. click.echo(f"Downloading new note {note.id}")
  420. new_notes += 1
  421. target_path = build_note_unique_path(notepath, note, date_format, local_index)
  422. local_path = local_index.get(note.id, LocalNote(note.id)).path
  423. if local_path:
  424. if rename_local and local_path != target_path:
  425. target_path = try_rename_note(local_index[note.id], target_path)
  426. else:
  427. target_path = local_path
  428. # decide to skip after the rename (due to date format change) has a chance
  429. if local_note:
  430. if local_note.timestamp_updated == note.timestamps.updated:
  431. skipped_notes += 1
  432. continue
  433. else:
  434. updated_notes += 1
  435. click.echo(f"Updating existing file for note {note.id}")
  436. images, downloaded = download_media(keep, note, mediapath, skip_existing_media)
  437. markdown = build_markdown(note, images)
  438. downloaded_media += downloaded
  439. with target_path.open("wb+") as f:
  440. if header:
  441. fmatter = build_frontmatter(note, markdown)
  442. frontmatter.dump(fmatter, f)
  443. else:
  444. f.write(markdown.encode("utf-8"))
  445. click.echo("Finished syncing.")
  446. click.echo(
  447. f"Notes: {skipped_notes} unchanged, {updated_notes} updated, {new_notes} new, {deleted_notes} deleted"
  448. )
  449. click.echo(f"Media: {downloaded_media} downloaded, {deleted_media} deleted")
  450. if __name__ == "__main__":
  451. main()