Просмотр исходного кода

Index local files using frontmatter, rename/delete/update notes accordingly, use date note prefix, change media path

Matthew Bafford 5 лет назад
Родитель
Сommit
edf370c52b
1 измененных файлов с 241 добавлено и 37 удалено
  1. 241 37
      keep_exporter/export.py

+ 241 - 37
keep_exporter/export.py

@@ -1,7 +1,9 @@
 #!/usr/bin/env python3
+import datetime
 import mimetypes
+import os
 import pathlib
-from typing import List
+from typing import Dict, List, NamedTuple, Optional
 
 import click
 import frontmatter
@@ -24,9 +26,9 @@ def login(user_email: str, password: str) -> gkeepapi.Keep:
 
 def download_images(
     keep: gkeepapi.Keep,
-    note_count: int,
     note: gkeepapi._node.Note,
-    outpath: pathlib.Path,
+    mediapath: pathlib.Path,
+    skip_existing: bool,
 ) -> List[pathlib.Path]:
     if not note.images and not note.drawings and not note.audio:
         return []
@@ -37,9 +39,6 @@ def download_images(
         meta = media.blob.save()
         # ocr = meta["extracted_text"]  # TODO save ocr as metadata? in markdown or image?
 
-        url = keep._media_api.get(media)
-        print("Downloading %s" % url)
-
         if meta.get("type", "") == "DRAWING":
             extension = mimetypes.guess_extension(
                 meta.get("drawingInfo", {})
@@ -48,17 +47,43 @@ def download_images(
             )  # All drawings seem to be pngs
         elif meta.get("type") == "IMAGE":
             extension = mimetypes.guess_extension(meta.get("mimetype", "image/jpeg"))
+            # .jpe just feels weird, but it's my default in testing
+            if extension == ".jpe":
+                extension = ".jpg"
         else:  # 'AUDIO'
             extension = mimetypes.guess_extension(meta.get("mimetype", "audio/3gpp"))
 
+        # nest media files under folders named by the note's ID
+        # this simplifies figuring out the note media files came from
+        note_media_path = mediapath / note.id
+        note_media_path.mkdir(exist_ok=True)
+
         media_file = (
-            outpath
-            / f'{sanitize_filename(f"{note_count:04} - " + media.server_id,max_len=135)}{extension}'
+            note_media_path
+            / f"{sanitize_filename(media.server_id,max_len=135)}{extension}"
         )
 
-        media_data = keep._media_api._session.get(url)
-        with media_file.open("wb") as f:
-            f.write(media_data.content)
+        # checking size isn't perfect, and drawings don't have a size,
+        # but it doesn't seem right to always re-download images that likely
+        # haven't changed
+        if (
+            skip_existing
+            and media_file.exists()
+            and hasattr(media.blob, "byte_size")
+            and media_file.stat().st_size == media.blob.byte_size
+        ):
+            click.echo(
+                f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
+            )
+        else:
+            print(
+                f"Downloading media {meta.get('type')} {media.server_id} for note {note.id}"
+            )
+
+            url = keep._media_api.get(media)
+            media_data = keep._media_api._session.get(url)
+            with media_file.open("wb") as f:
+                f.write(media_data.content)
 
         ret.append(media_file)
 
@@ -67,7 +92,7 @@ def download_images(
 
 def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
     metadata = {
-        "id": note.id,
+        "google_keep_id": note.id,
         "title": note.title,
         "pinned": note.pinned,
         "trashed": note.trashed,
@@ -129,6 +154,125 @@ def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str
     return doc.file_data_text
 
 
+MarkdownFile = NamedTuple(
+    "MarkdownFile",
+    [
+        ("path", pathlib.Path),
+        ("timestamp_updated", Optional[datetime.datetime]),
+        ("google_keep_id", Optional[str]),
+    ],
+)
+
+MediaFile = NamedTuple(
+    "MediaFile",
+    [
+        ("path", pathlib.Path),
+        ("parent_id", Optional[str]),
+        ("google_keep_id", Optional[str]),
+    ],
+)
+
+
+def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
+    index = {}
+
+    keep_notes = 0
+    unknown_notes = 0
+    errors = 0
+    media = 0
+
+    for file in directory.rglob("*"):
+        # markdown file
+        if file.name.endswith(".md"):
+            try:
+                with open(file, "r") as f:
+                    fm = frontmatter.load(f)
+
+                    info = MarkdownFile(
+                        file,
+                        fm.metadata.get("timestamps").get("updated"),
+                        fm.metadata.get("google_keep_id"),
+                    )
+
+                    if info.google_keep_id:
+                        keep_notes += 1
+                        index[info.google_keep_id] = info
+                    else:
+                        unknown_notes += 1
+            except IOError as ex:
+                errors = 0
+                click.echo(
+                    "Unable to open Markdown file [{os.path.join(root, file)}]. Skipping: {str(ex)}",
+                    err=True,
+                )
+
+        # media file
+        else:
+            media += 1
+            click.echo(f"Ignoring media file [{file}]")
+
+    click.echo(
+        f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
+    )
+
+    return index
+
+
+def try_rename_note(note: MarkdownFile, target_file: pathlib.Path) -> pathlib.Path:
+    """
+    Attempts to rename an existing note to the new canonical filename,
+    but accepts failures to rename. Returns the path the note now exists
+    in, either the old path or the new renamed path.
+    """
+    click.echo(f"Renaming [{note.path}] to [{target_file}]")
+
+    try:
+        note.path.rename(target_file)
+        return target_file
+    except Exception as ex:
+        click.echo(f"Unable to rename note. Using existing name: %s" % ex, err=True)
+        return note.path
+
+
+def build_note_unique_path(
+    notepath: pathlib.Path,
+    note: gkeepapi._node.Note,
+    date_format: str,
+    local_index: Dict[str, MarkdownFile],
+) -> pathlib.Path:
+    title = note.title.strip()
+    if not len(title):
+        title = "untitled"
+
+    date_str = note.timestamps.created.strftime(date_format)
+    filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
+    target_path = notepath / filename
+
+    if note.id in local_index:
+        # if the note filename matches the current filename for that note, then we're good
+        if local_index[note.id].path == target_path:
+            return local_index[note.id].path
+
+        # if re-naming would result in having to de-dupe the target filename, keep the
+        # exising filename - initial pass at fixing this just resulted in bouncing between
+        # two different filenames each pass
+        if target_path.exists():
+            click.echo(
+                f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
+            )
+            return local_index[note.id].path
+
+    # otherwise, if the file already exists avoid overwriting it
+    # put the unique note ID and an incrementing index at the end of the filename
+    dedupe_index = 1
+    while target_path.exists():
+        filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.{note.id}.{dedupe_index}.md'
+        target_path = notepath / filename
+        dedupe_index += 1
+
+    return target_path
+
+
 @click.command(
     context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
 )
@@ -165,39 +309,99 @@ def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str
     show_default=True,
     help="Choose to include or exclude the frontmatter header",
 )
-def main(directory, user, password, 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."""
-    outpath = pathlib.Path(directory).resolve()
+    notepath = pathlib.Path(directory).resolve()
+    mediapath = notepath.joinpath("media/")
+
+    click.echo(f"Notes directory: {notepath}")
+    click.echo(f"Media directory: {mediapath}")
 
-    click.echo(f"Output directory: {outpath}")
     click.echo("Logging in.")
     keep = login(user, password)
 
-    click.echo("Beginning note export.")
-
-    if not outpath.exists():
-        click.echo("output directory does not exist, creating.")
-        outpath.mkdir(parents=True)
-
-    notes: List[gkeepapi.node.TopLevelNode] = keep.all()
-    note_count = 0
-    for note in notes:  # type: gkeepapi.node.TopLevelNode
-        note_count += 1
-        click.echo(f"Processing note #{note_count}")
-
-        title = note.title.strip()
-        if not len(title):
-            title = "untitled"
-
-        outfile = (
-            outpath
-            / f'{sanitize_filename(f"{note_count:04} - " + title,max_len=135)}.md'
-        )
+    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()])
+
+    deleted_notes = set(local_index.keys()).difference(set(keep_notes.keys()))
+
+    if deleted_notes:
+        if not delete_local:
+            click.echo(
+                f"{len(deleted_notes)} notes exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
+            )
+        else:
+            click.echo(
+                f"{len(deleted_notes)} notes exist locally, but not in Google Keep. Trashing local files."
+            )
+            for note_id in deleted_notes:
+                click.echo(
+                    f"    Deleting unknown local note [{note_id}] file [{local_index[note_id].path}]"
+                )
+                local_index[note_id].path.unlink()
+
+    for note in keep_notes.values():  # type: gkeepapi.node.TopLevelNode
+        if note.id in local_index:
+            click.echo(f"Updating existing file for note {note.id}")
+        else:
+            click.echo(f"Downloading new note {note.id}")
+
+        target_path = build_note_unique_path(notepath, note, date_format, local_index)
+
+        if note.id in local_index:
+            if rename_local and local_index[note.id].path != target_path:
+                target_path = try_rename_note(local_index[note.id], target_path)
+            else:
+                target_path = local_index[note.id].path
 
-        images = download_images(keep, note_count, note, outpath)
+        images = download_images(keep, note, mediapath, skip_existing_media)
         markdown = build_markdown(note, images)
 
-        with outfile.open("wb+") as f:
+        with target_path.open("wb+") as f:
             if header:
                 fmatter = build_frontmatter(note, markdown)
                 frontmatter.dump(fmatter, f)