|
@@ -1,12 +1,13 @@
|
|
|
#!/usr/bin/env python3
|
|
#!/usr/bin/env python3
|
|
|
|
|
+# pylint: disable=protected-access
|
|
|
import datetime
|
|
import datetime
|
|
|
import mimetypes
|
|
import mimetypes
|
|
|
import pathlib
|
|
import pathlib
|
|
|
-from typing import (Dict, List, NamedTuple, Optional, Set, Tuple, Union,
|
|
|
|
|
- ValuesView)
|
|
|
|
|
|
|
+from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView
|
|
|
|
|
|
|
|
import click
|
|
import click
|
|
|
-import click_config_file
|
|
|
|
|
|
|
+
|
|
|
|
|
+# import click_config_file
|
|
|
import frontmatter
|
|
import frontmatter
|
|
|
import gkeepapi
|
|
import gkeepapi
|
|
|
from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
|
|
from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
|
|
@@ -15,6 +16,7 @@ from pathvalidate import sanitize_filename
|
|
|
|
|
|
|
|
mimetypes.add_type("audio/3gpp", ".3gp")
|
|
mimetypes.add_type("audio/3gpp", ".3gp")
|
|
|
|
|
|
|
|
|
|
+
|
|
|
def all_note_media(
|
|
def all_note_media(
|
|
|
note: gkeepapi._node.Note,
|
|
note: gkeepapi._node.Note,
|
|
|
) -> List[Union[NodeImage, NodeDrawing, NodeAudio]]:
|
|
) -> List[Union[NodeImage, NodeDrawing, NodeAudio]]:
|
|
@@ -25,17 +27,12 @@ def all_note_media(
|
|
|
"""
|
|
"""
|
|
|
return note.images + note.drawings + note.audio
|
|
return note.images + note.drawings + note.audio
|
|
|
|
|
|
|
|
-
|
|
|
|
|
-def all_note_media( note: gkeepapi._node.Note ) -> List[Union[NodeImage, NodeDrawing, NodeAudio]]:
|
|
|
|
|
- """
|
|
|
|
|
- Returns a filtered list of only "media" blobs associate with the note.
|
|
|
|
|
- Currently NodeDrawing, NodeImage, and NodeMedia.
|
|
|
|
|
- There are other blob types, but they don't seem actionable as media.
|
|
|
|
|
- """
|
|
|
|
|
- return note.images + note.drawings + note.audio
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-def download_media( keep: gkeepapi.Keep, note: gkeepapi._node.Note, mediapath: pathlib.Path, skip_existing: bool) -> Tuple[List[pathlib.Path], int]:
|
|
|
|
|
|
|
+def download_media(
|
|
|
|
|
+ keep: gkeepapi.Keep,
|
|
|
|
|
+ note: gkeepapi._node.Note,
|
|
|
|
|
+ mediapath: pathlib.Path,
|
|
|
|
|
+ skip_existing: bool,
|
|
|
|
|
+) -> Tuple[List[pathlib.Path], int]:
|
|
|
|
|
|
|
|
note_media = all_note_media(note)
|
|
note_media = all_note_media(note)
|
|
|
if not note_media:
|
|
if not note_media:
|
|
@@ -88,8 +85,8 @@ def download_media( keep: gkeepapi.Keep, note: gkeepapi._node.Note, mediapath: p
|
|
|
|
|
|
|
|
url = keep._media_api.get(media)
|
|
url = keep._media_api.get(media)
|
|
|
media_data = keep._media_api._session.get(url)
|
|
media_data = keep._media_api._session.get(url)
|
|
|
- with media_file.open("wb") as f:
|
|
|
|
|
- f.write(media_data.content)
|
|
|
|
|
|
|
+ with media_file.open("wb") as file_handle:
|
|
|
|
|
+ file_handle.write(media_data.content)
|
|
|
|
|
|
|
|
downloaded_media += 1
|
|
downloaded_media += 1
|
|
|
|
|
|
|
@@ -118,7 +115,8 @@ def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.P
|
|
|
},
|
|
},
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- # 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.
|
|
|
|
|
|
|
+ # 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.
|
|
|
if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
|
|
if note.timestamps.trashed and note.timestamps.trashed.year > 1970:
|
|
|
metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
|
|
metadata["timestamps"]["trashed"] = note.timestamps.trashed.timestamp()
|
|
|
if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
|
|
if note.timestamps.deleted and note.timestamps.deleted.year > 1970:
|
|
@@ -130,7 +128,7 @@ def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.P
|
|
|
def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
|
|
def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str:
|
|
|
doc = MdUtils(
|
|
doc = MdUtils(
|
|
|
""
|
|
""
|
|
|
- ) # mdutils requires a string file name. Since we're not using it to write files, we can ignore that.
|
|
|
|
|
|
|
+ ) # mdutils requires a string file name. We're not using it to write files, ignore it.
|
|
|
|
|
|
|
|
doc.new_header(1, note.title)
|
|
doc.new_header(1, note.title)
|
|
|
doc.new_header(2, "Note")
|
|
doc.new_header(2, "Note")
|
|
@@ -161,6 +159,15 @@ def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str
|
|
|
|
|
|
|
|
return doc.file_data_text
|
|
return doc.file_data_text
|
|
|
|
|
|
|
|
|
|
+def write_note(target_path, header, note, markdown):
|
|
|
|
|
+ """ Writes built notes to disk, optionally builds a header/frontmatter too."""
|
|
|
|
|
+ with target_path.open("wb+") as file_handle:
|
|
|
|
|
+ if header:
|
|
|
|
|
+ front_matter = build_frontmatter(note, markdown)
|
|
|
|
|
+ frontmatter.dump(front_matter, file_handle)
|
|
|
|
|
+ else:
|
|
|
|
|
+ file_handle.write(markdown.encode("utf-8"))
|
|
|
|
|
+
|
|
|
|
|
|
|
|
LocalMedia = NamedTuple(
|
|
LocalMedia = NamedTuple(
|
|
|
"LocalMedia",
|
|
"LocalMedia",
|
|
@@ -211,8 +218,8 @@ def index_existing_files(directory: pathlib.Path) -> Dict[str, LocalNote]:
|
|
|
# markdown file
|
|
# markdown file
|
|
|
if file.name.endswith(".md"):
|
|
if file.name.endswith(".md"):
|
|
|
try:
|
|
try:
|
|
|
- with open(file, "r") as f:
|
|
|
|
|
- fm = frontmatter.load(f)
|
|
|
|
|
|
|
+ with open(file, "r") as file_handle:
|
|
|
|
|
+ fm = frontmatter.load(file_handle)
|
|
|
|
|
|
|
|
google_keep_id: str = fm.metadata.get("google_keep_id")
|
|
google_keep_id: str = fm.metadata.get("google_keep_id")
|
|
|
if google_keep_id:
|
|
if google_keep_id:
|
|
@@ -239,7 +246,7 @@ def index_existing_files(directory: pathlib.Path) -> Dict[str, LocalNote]:
|
|
|
except IOError as ex:
|
|
except IOError as ex:
|
|
|
errors = 0
|
|
errors = 0
|
|
|
click.echo(
|
|
click.echo(
|
|
|
- "Unable to open Markdown file [{os.path.join(root, file)}]. Skipping: {str(ex)}",
|
|
|
|
|
|
|
+ f"Unable to open Markdown file {file}. Skipping: {str(ex)}",
|
|
|
err=True,
|
|
err=True,
|
|
|
)
|
|
)
|
|
|
|
|
|
|
@@ -277,7 +284,7 @@ def try_rename_note(note: LocalNote, target_file: pathlib.Path) -> pathlib.Path:
|
|
|
note.path.rename(target_file)
|
|
note.path.rename(target_file)
|
|
|
return target_file
|
|
return target_file
|
|
|
except Exception as ex:
|
|
except Exception as ex:
|
|
|
- click.echo(f"Unable to rename note. Using existing name: %s" % ex, err=True)
|
|
|
|
|
|
|
+ click.echo(f"Unable to rename note. Using existing name: {ex}", err=True)
|
|
|
return note.path
|
|
return note.path
|
|
|
|
|
|
|
|
|
|
|
|
@@ -392,147 +399,3 @@ def delete_local_only_files(
|
|
|
deleted_media += 1
|
|
deleted_media += 1
|
|
|
|
|
|
|
|
return (deleted_notes, deleted_media)
|
|
return (deleted_notes, deleted_media)
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
-@click.command(
|
|
|
|
|
- context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--user",
|
|
|
|
|
- "-u",
|
|
|
|
|
- prompt=True,
|
|
|
|
|
- required=True,
|
|
|
|
|
- envvar="GKEEP_USER",
|
|
|
|
|
- show_envvar=True,
|
|
|
|
|
- help="Google account email (prompt if empty)",
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--password",
|
|
|
|
|
- "-p",
|
|
|
|
|
- prompt=True,
|
|
|
|
|
- required=True,
|
|
|
|
|
- envvar="GKEEP_PASSWORD",
|
|
|
|
|
- show_envvar=True,
|
|
|
|
|
- help="Google account password (prompt if empty)",
|
|
|
|
|
- hide_input=True,
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--directory",
|
|
|
|
|
- "-d",
|
|
|
|
|
- default="./gkeep-export",
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Output directory for exported notes",
|
|
|
|
|
- type=click.Path(file_okay=False, dir_okay=True, writable=True),
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--header/--no-header",
|
|
|
|
|
- default=True,
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Choose to include or exclude the frontmatter header",
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--delete-local/--no-delete-local",
|
|
|
|
|
- default=False,
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Choose to delete or leave as-is any notes that exist locally but not in Google Keep",
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--rename-local/--no-rename-local",
|
|
|
|
|
- default=False,
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Choose to rename or leave as-is any notes that change titles in Google Keep",
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--date-format",
|
|
|
|
|
- default="%Y-%m-%d",
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Date format to use for the prefix of the note filenames. Reflects the created date of the note.",
|
|
|
|
|
-)
|
|
|
|
|
-@click.option(
|
|
|
|
|
- "--skip-existing-media/--no-skip-existing-media",
|
|
|
|
|
- default=True,
|
|
|
|
|
- show_default=True,
|
|
|
|
|
- help="Skip existing media if it appears unchanged from the local copy.",
|
|
|
|
|
-)
|
|
|
|
|
-def main(
|
|
|
|
|
- directory: str,
|
|
|
|
|
- user: str,
|
|
|
|
|
- password: str,
|
|
|
|
|
- header: bool,
|
|
|
|
|
- delete_local: bool,
|
|
|
|
|
- rename_local: bool,
|
|
|
|
|
- date_format: str,
|
|
|
|
|
- skip_existing_media: bool,
|
|
|
|
|
-):
|
|
|
|
|
- """A simple utility to export google keep notes to markdown files with metadata stored as a frontmatter header."""
|
|
|
|
|
- notepath = pathlib.Path(directory).resolve()
|
|
|
|
|
- mediapath = notepath.joinpath("media/")
|
|
|
|
|
-
|
|
|
|
|
- click.echo(f"Notes directory: {notepath}")
|
|
|
|
|
- click.echo(f"Media directory: {mediapath}")
|
|
|
|
|
-
|
|
|
|
|
- click.echo("Logging in.")
|
|
|
|
|
- keep = login(user, password)
|
|
|
|
|
-
|
|
|
|
|
- if not notepath.exists():
|
|
|
|
|
- click.echo("Notes directory does not exist, creating.")
|
|
|
|
|
- notepath.mkdir(parents=True)
|
|
|
|
|
-
|
|
|
|
|
- if not mediapath.exists():
|
|
|
|
|
- click.echo("Media directory does not exist, creating.")
|
|
|
|
|
- mediapath.mkdir(parents=True)
|
|
|
|
|
-
|
|
|
|
|
- click.echo("Indexing local files.")
|
|
|
|
|
- local_index = index_existing_files(notepath)
|
|
|
|
|
-
|
|
|
|
|
- click.echo("Indexing remote notes.")
|
|
|
|
|
- keep_notes = dict([(note.id, note) for note in keep.all()])
|
|
|
|
|
-
|
|
|
|
|
- skipped_notes, updated_notes, new_notes = 0, 0, 0
|
|
|
|
|
- downloaded_media = 0
|
|
|
|
|
- deleted_notes, deleted_media = delete_local_only_files(
|
|
|
|
|
- local_index, keep_notes, delete_local
|
|
|
|
|
- )
|
|
|
|
|
-
|
|
|
|
|
- for note in keep_notes.values(): # type: gkeepapi._node.Note
|
|
|
|
|
- local_note = local_index.get(note.id)
|
|
|
|
|
- if not local_note:
|
|
|
|
|
- click.echo(f"Downloading new note {note.id}")
|
|
|
|
|
- new_notes += 1
|
|
|
|
|
-
|
|
|
|
|
- target_path = build_note_unique_path(notepath, note, date_format, local_index)
|
|
|
|
|
-
|
|
|
|
|
- local_path = local_index.get(note.id, LocalNote(note.id)).path
|
|
|
|
|
- if local_path:
|
|
|
|
|
- if rename_local and local_path != target_path:
|
|
|
|
|
- target_path = try_rename_note(local_index[note.id], target_path)
|
|
|
|
|
- else:
|
|
|
|
|
- target_path = local_path
|
|
|
|
|
-
|
|
|
|
|
- # decide to skip after the rename (due to date format change) has a chance
|
|
|
|
|
- if local_note:
|
|
|
|
|
- if local_note.timestamp_updated == note.timestamps.updated:
|
|
|
|
|
- skipped_notes += 1
|
|
|
|
|
- continue
|
|
|
|
|
- else:
|
|
|
|
|
- updated_notes += 1
|
|
|
|
|
- click.echo(f"Updating existing file for note {note.id}")
|
|
|
|
|
-
|
|
|
|
|
-
|
|
|
|
|
- images, downloaded = download_media(keep, note, mediapath, skip_existing_media)
|
|
|
|
|
- markdown = build_markdown(note, images)
|
|
|
|
|
-
|
|
|
|
|
- downloaded_media += downloaded
|
|
|
|
|
-
|
|
|
|
|
- with target_path.open("wb+") as f:
|
|
|
|
|
- if header:
|
|
|
|
|
- fmatter = build_frontmatter(note, markdown)
|
|
|
|
|
- frontmatter.dump(fmatter, f)
|
|
|
|
|
- else:
|
|
|
|
|
- f.write(markdown.encode("utf-8"))
|
|
|
|
|
-
|
|
|
|
|
- click.echo("Finished syncing.")
|
|
|
|
|
- click.echo(
|
|
|
|
|
- f"Notes: {skipped_notes} unchanged, {updated_notes} updated, {new_notes} new, {deleted_notes} deleted"
|
|
|
|
|
- )
|
|
|
|
|
- click.echo(f"Media: {downloaded_media} downloaded, {deleted_media} deleted")
|
|
|