export.py 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438
  1. #!/usr/bin/env python3
  2. """Library functions that do the heavy lifting of this package."""
  3. # pylint: disable=protected-access
  4. import datetime
  5. import mimetypes
  6. import pathlib
  7. from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView
  8. import click
  9. # import click_config_file
  10. import frontmatter
  11. import gkeepapi
  12. from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
  13. from mdutils.mdutils import MdUtils
  14. from pathvalidate import sanitize_filename
  15. mimetypes.add_type("audio/3gpp", ".3gp")
  16. def all_note_media(
  17. note: gkeepapi._node.Note,
  18. ) -> List[Union[NodeImage, NodeDrawing, NodeAudio]]:
  19. """
  20. Returns a filtered list of only "media" blobs associate with the note.
  21. Currently NodeDrawing, NodeImage, and NodeMedia.
  22. There are other blob types, but they don't seem actionable as media.
  23. """
  24. return note.images + note.drawings + note.audio
  25. def download_media(
  26. keep: gkeepapi.Keep,
  27. note: gkeepapi._node.Note,
  28. mediapath: pathlib.Path,
  29. skip_existing: bool,
  30. ) -> Tuple[List[pathlib.Path], int]:
  31. """Downloads media files from keep
  32. Args:
  33. keep (gkeepapi.Keep): keep instance
  34. note (gkeepapi._node.Note): specific note to download media from
  35. mediapath (pathlib.Path): path to put media
  36. skip_existing (bool): skip existing media?
  37. Returns:
  38. Tuple[List[pathlib.Path], int]: List of paths where media was put
  39. """
  40. note_media = all_note_media(note)
  41. if not note_media:
  42. return ([], 0)
  43. ret = []
  44. downloaded_media = 0
  45. for media in note_media:
  46. meta = media.blob.save()
  47. # ocr = meta["extracted_text"] # TODO save ocr as metadata? in markdown or image?
  48. if meta.get("type", "") == "DRAWING":
  49. extension = mimetypes.guess_extension(
  50. meta.get("drawingInfo", {})
  51. .get("snapshotData", {})
  52. .get("mimetype", "image/png")
  53. ) # All drawings seem to be pngs
  54. elif meta.get("type") == "IMAGE":
  55. extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
  56. # .jpe just feels weird, but it's my default in testing
  57. if extension == ".jpe":
  58. extension = ".jpg"
  59. else: # 'AUDIO'
  60. extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
  61. # nest media files under folders named by the note's ID
  62. # this simplifies figuring out the note media files came from
  63. note_media_path = mediapath / note.id
  64. note_media_path.mkdir(exist_ok=True)
  65. media_file = (
  66. note_media_path / f"{sanitize_filename(media.id,max_len=135)}{extension}"
  67. )
  68. # checking size isn't perfect, and drawings don't have a size,
  69. # but it doesn't seem right to always re-download images that likely
  70. # haven't changed
  71. if (
  72. skip_existing
  73. and media_file.exists()
  74. and hasattr(media.blob, "byte_size")
  75. and media_file.stat().st_size == media.blob.byte_size
  76. ):
  77. click.echo(
  78. f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
  79. )
  80. else:
  81. print(f"Downloading media {meta.get('type')} {media.id} for note {note.id}")
  82. url = keep._media_api.get(media)
  83. media_data = keep._media_api._session.get(url)
  84. with media_file.open("wb") as file_handle:
  85. file_handle.write(media_data.content)
  86. downloaded_media += 1
  87. ret.append(media_file)
  88. return (ret, downloaded_media)
  89. def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
  90. """Builds the frontmatter header put at the top of notes
  91. Args:
  92. note (gkeepapi._node.Note): Note to build header from
  93. markdown (str): markdown body
  94. Returns:
  95. frontmatter.Post: Completed header + body
  96. """
  97. metadata = {
  98. "title": note.title,
  99. "url": note.url,
  100. "pinned": note.pinned,
  101. "archived": note.archived,
  102. "trashed": note.trashed,
  103. # "deleted": note.deleted, # unused, when google keep actually ~deletes~ a note, the api no longer returns it
  104. "tags": [label.name for label in note.labels.all()],
  105. "color": note.color.name,
  106. "timestamps": {
  107. "created": note.timestamps.created.timestamp(),
  108. "edited": note.timestamps.edited.timestamp(),
  109. "updated": note.timestamps.updated.timestamp(),
  110. },
  111. "google_keep_id": note.id,
  112. "type": note.type.name,
  113. "parent_id": note.parent_id,
  114. "sort": note.sort,
  115. }
  116. # gkeepapi appears to be treating "0" as a timestamp rather than null.
  117. # Sometimes the data structure does not have the key at all instead of 0.
  118. if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
  119. metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
  120. if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
  121. metadata["timestamps"]["deleted"] = note.timestamps.deleted.timestamp()
  122. return frontmatter.Post(markdown, handler=None, **metadata)
  123. def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
  124. """builds the markdown body from a given note
  125. Args:
  126. note (gkeepapi._node.Note): Note to build body out of
  127. images (List[pathlib.Path]): Images embedded in body
  128. Returns:
  129. str: string representation of the body contents
  130. """
  131. doc = MdUtils(
  132. ""
  133. ) # mdutils requires a string file name. We're not using it to write files, ignore it.
  134. doc.new_header(1, note.title)
  135. doc.new_header(2, "Note")
  136. text = note.text
  137. text = text.replace("☑ ", "- [X] ")
  138. text = text.replace("☐ ", "- [ ] ")
  139. doc.new_paragraph(text)
  140. if note.annotations.links:
  141. doc.new_line()
  142. doc.new_line()
  143. doc.new_header(2, "Links")
  144. doc.new_list(
  145. [
  146. doc.new_inline_link(link=link.url, text=link.title)
  147. for link in note.annotations.links
  148. ]
  149. )
  150. if images:
  151. doc.new_line()
  152. doc.new_header(2, "Attached Media")
  153. for image in images:
  154. doc.new_line(doc.new_inline_image("", image.name))
  155. return doc.file_data_text
  156. def write_note(target_path, header, note, markdown):
  157. """Writes built notes to disk, optionally builds a header/frontmatter too."""
  158. with target_path.open("wb+") as file_handle:
  159. if header:
  160. front_matter = build_frontmatter(note, markdown)
  161. frontmatter.dump(
  162. front_matter, file_handle, sort_keys=False
  163. ) # don't sort frontmatter keys
  164. else:
  165. file_handle.write(markdown.encode("utf-8"))
  166. LocalMedia = NamedTuple(
  167. "LocalMedia",
  168. [
  169. ("path", pathlib.Path),
  170. ("google_keep_note_id", str),
  171. ("google_keep_media_id", str),
  172. ],
  173. )
  174. class LocalNote:
  175. def __init__(
  176. self,
  177. google_keep_id: str,
  178. path: Optional[pathlib.Path] = None,
  179. timestamp_updated: Optional[datetime.datetime] = None,
  180. local_media: Dict[str, LocalMedia] = None,
  181. ):
  182. self.google_keep_id = google_keep_id
  183. self.path = path
  184. self.timestamp_updated = timestamp_updated
  185. if not local_media:
  186. self.local_media: Dict[str, LocalMedia] = {}
  187. else:
  188. self.local_media = local_media
  189. def index_existing_files(directory: pathlib.Path) -> Dict[str, LocalNote]:
  190. """
  191. Scans the output folder looking for existing markdown files
  192. and media files and builds an index by google_keep_id of those files
  193. using the metadata in the markdown frontmatter and the filenames
  194. of the media files.
  195. """
  196. index: Dict[str, LocalNote] = {}
  197. keep_notes = 0
  198. unknown_notes = 0
  199. errors = 0
  200. media = 0
  201. for file in directory.rglob("*"):
  202. if not file.is_file():
  203. continue
  204. # markdown file
  205. if file.name.endswith(".md"):
  206. try:
  207. with file.open("rt") as file_handle:
  208. note_frontmatter = frontmatter.load(file_handle)
  209. google_keep_id: str = note_frontmatter.metadata.get("google_keep_id")
  210. if google_keep_id:
  211. if google_keep_id in index and index[google_keep_id].path:
  212. click.echo(
  213. f"Same Google Keep ID {google_keep_id} in multiple files:\n"
  214. f" {file}\n"
  215. f" {index[google_keep_id].path}\n"
  216. f"Only the last file will be updated."
  217. )
  218. keep_notes += 1
  219. index.setdefault(google_keep_id, LocalNote(google_keep_id))
  220. updated: datetime.datetime = datetime.datetime.fromtimestamp(
  221. note_frontmatter.metadata.get("timestamps", {}).get("updated")
  222. )
  223. index[google_keep_id].timestamp_updated = updated
  224. index[google_keep_id].path = file
  225. else:
  226. unknown_notes += 1
  227. except IOError as ex:
  228. errors = 0
  229. click.echo(
  230. f"Unable to open Markdown file {file}. Skipping: {str(ex)}",
  231. err=True,
  232. )
  233. # media file
  234. else:
  235. media += 1
  236. google_keep_id = file.parent.name
  237. media_id = ".".join(file.name.split(".")[0:2])
  238. index.setdefault(google_keep_id, LocalNote(google_keep_id))
  239. index[google_keep_id].local_media[media_id] = LocalMedia(
  240. file, google_keep_id, media_id
  241. )
  242. click.echo(
  243. f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
  244. )
  245. return index
  246. def try_rename_note(note: LocalNote, target_file: pathlib.Path) -> pathlib.Path:
  247. """
  248. Attempts to rename an existing note to the new canonical filename,
  249. but accepts failures to rename. Returns the path the note now exists
  250. in, either the old path or the new renamed path.
  251. """
  252. if not note.path:
  253. return target_file
  254. click.echo(f"Renaming [{note.path}] to [{target_file}]")
  255. try:
  256. note.path.rename(target_file)
  257. return target_file
  258. # pylint: disable=broad-except
  259. except Exception as ex:
  260. click.echo(f"Unable to rename note. Using existing name: {ex}", err=True)
  261. return note.path
  262. def build_note_unique_path(
  263. notepath: pathlib.Path,
  264. note: gkeepapi._node.Note,
  265. date_format: str,
  266. local_index: Dict[str, LocalNote],
  267. ) -> pathlib.Path:
  268. title = note.title.strip()
  269. if len(title) < 1:
  270. title = "untitled"
  271. date_str = note.timestamps.created.strftime(date_format)
  272. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
  273. target_path = notepath / filename
  274. local_note = local_index.get(note.id)
  275. local_path = local_note.path if local_note else None
  276. if local_path:
  277. # if the note filename matches the current filename for that note, then we're good
  278. if local_path == target_path:
  279. return local_path
  280. # if re-naming would result in having to de-dupe the target filename, keep the
  281. # exising filename - initial pass at fixing this just resulted in bouncing between
  282. # two different filenames each pass
  283. if target_path.exists():
  284. click.echo(
  285. f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
  286. )
  287. return local_path
  288. # otherwise, if the file already exists avoid overwriting it
  289. # put the unique note ID and an incrementing index at the end of the filename
  290. dedupe_index = 1
  291. while target_path.exists():
  292. filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.{note.id}.{dedupe_index}.md'
  293. target_path = notepath / filename
  294. dedupe_index += 1
  295. return target_path
  296. def delete_local_only_files(
  297. local_index: Dict[str, LocalNote],
  298. keep_notes: Dict[str, List[gkeepapi.node.Note]],
  299. delete_local: bool,
  300. ) -> Tuple[int, int]:
  301. """
  302. Checks the local index for any notes or media that exist only locally
  303. and were not returned in the Google Keep API call.
  304. """
  305. deleted_notes, deleted_media = 0, 0
  306. local_only_note_ids = set(local_index.keys()).difference(set(keep_notes.keys()))
  307. if local_only_note_ids:
  308. if not delete_local:
  309. click.echo(
  310. f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
  311. )
  312. else:
  313. click.echo(
  314. f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Trashing local files."
  315. )
  316. for note_id in local_only_note_ids:
  317. note_path = local_index[note_id].path
  318. deleted_notes += 1
  319. if note_path:
  320. click.echo(
  321. f" Deleting unknown local note [{note_id}] file [{local_index[note_id].path}]"
  322. )
  323. note_path.unlink()
  324. local_only_media: Set[Tuple[str, str]] = set(
  325. [
  326. (local_media.google_keep_note_id, local_media.google_keep_media_id)
  327. for local_note in local_index.values()
  328. for local_media in local_note.local_media.values()
  329. if local_media.google_keep_note_id and local_media.google_keep_media_id
  330. ]
  331. )
  332. notes: ValuesView[gkeepapi._node.Note] = keep_notes.values()
  333. keep_media: Set[Tuple[str, str]] = set(
  334. [
  335. (keep_note.id, keep_media.id)
  336. for keep_note in notes
  337. for keep_media in all_note_media(keep_note)
  338. ]
  339. )
  340. local_only_media_ids = local_only_media.difference(keep_media)
  341. if not local_only_media_ids:
  342. return (deleted_notes, 0)
  343. if not delete_local:
  344. click.echo(
  345. f"{len(local_only_note_ids)} media files exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
  346. )
  347. return (deleted_notes, 0)
  348. for (note_id, media_id) in local_only_media_ids:
  349. media = local_index[note_id].local_media[media_id]
  350. click.echo(
  351. f" Deleting media [{media_id}] for note [{note_id}] file [{media.path}]"
  352. )
  353. media.path.unlink()
  354. deleted_media += 1
  355. return (deleted_notes, deleted_media)