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

Index media files, delete media files, add warnings and status counts

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

+ 194 - 63
keep_exporter/export.py

@@ -3,11 +3,13 @@ import datetime
 import mimetypes
 import mimetypes
 import os
 import os
 import pathlib
 import pathlib
-from typing import Dict, List, NamedTuple, Optional
+import typing
+from typing import Dict, List, NamedTuple, Optional, Set, Tuple, Union, ValuesView
 
 
 import click
 import click
 import frontmatter
 import frontmatter
 import gkeepapi
 import gkeepapi
+from gkeepapi.node import NodeAudio, NodeDrawing, NodeImage
 from mdutils.mdutils import MdUtils
 from mdutils.mdutils import MdUtils
 from pathvalidate import sanitize_filename
 from pathvalidate import sanitize_filename
 
 
@@ -24,18 +26,32 @@ def login(user_email: str, password: str) -> gkeepapi.Keep:
     return keep
     return keep
 
 
 
 
+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_images(
 def download_images(
     keep: gkeepapi.Keep,
     keep: gkeepapi.Keep,
     note: gkeepapi._node.Note,
     note: gkeepapi._node.Note,
     mediapath: pathlib.Path,
     mediapath: pathlib.Path,
     skip_existing: bool,
     skip_existing: bool,
-) -> List[pathlib.Path]:
-    if not note.images and not note.drawings and not note.audio:
-        return []
+) -> Tuple[List[pathlib.Path], int]:
+
+    note_media = all_note_media(note)
+    if not note_media:
+        return ([], 0)
 
 
     ret = []
     ret = []
+    downloaded_media = 0
 
 
-    for media in note.images + note.drawings + note.audio:
+    for media in note_media:
         meta = media.blob.save()
         meta = media.blob.save()
         # ocr = meta["extracted_text"]  # TODO save ocr as metadata? in markdown or image?
         # ocr = meta["extracted_text"]  # TODO save ocr as metadata? in markdown or image?
 
 
@@ -59,8 +75,7 @@ def download_images(
         note_media_path.mkdir(exist_ok=True)
         note_media_path.mkdir(exist_ok=True)
 
 
         media_file = (
         media_file = (
-            note_media_path
-            / f"{sanitize_filename(media.server_id,max_len=135)}{extension}"
+            note_media_path / f"{sanitize_filename(media.id,max_len=135)}{extension}"
         )
         )
 
 
         # checking size isn't perfect, and drawings don't have a size,
         # checking size isn't perfect, and drawings don't have a size,
@@ -76,18 +91,18 @@ def download_images(
                 f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
                 f"Media file f{media_file} exists and is same size as in Google Keep. Skipping."
             )
             )
         else:
         else:
-            print(
-                f"Downloading media {meta.get('type')} {media.server_id} for note {note.id}"
-            )
+            print(f"Downloading media {meta.get('type')} {media.id} for note {note.id}")
 
 
             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:
             with media_file.open("wb") as f:
                 f.write(media_data.content)
                 f.write(media_data.content)
 
 
+            downloaded_media += 1
+
         ret.append(media_file)
         ret.append(media_file)
 
 
-    return ret
+    return (ret, downloaded_media)
 
 
 
 
 def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
 def build_frontmatter(note: gkeepapi._node.Note, markdown: str) -> frontmatter.Post:
@@ -154,27 +169,42 @@ def build_markdown(note: gkeepapi._node.Note, images: List[pathlib.Path]) -> str
     return doc.file_data_text
     return doc.file_data_text
 
 
 
 
-MarkdownFile = NamedTuple(
-    "MarkdownFile",
+LocalMedia = NamedTuple(
+    "LocalMedia",
     [
     [
         ("path", pathlib.Path),
         ("path", pathlib.Path),
-        ("timestamp_updated", Optional[datetime.datetime]),
-        ("google_keep_id", Optional[str]),
+        ("google_keep_note_id", str),
+        ("google_keep_media_id", str),
     ],
     ],
 )
 )
 
 
-MediaFile = NamedTuple(
-    "MediaFile",
-    [
-        ("path", pathlib.Path),
-        ("parent_id", Optional[str]),
-        ("google_keep_id", Optional[str]),
-    ],
-)
+
+class LocalNote:
+    def __init__(
+        self,
+        google_keep_id: str,
+        path: Optional[pathlib.Path] = None,
+        timestamp_updated: Optional[datetime.datetime] = None,
+        local_media: Dict[str, LocalMedia] = None,
+    ):
+        self.google_keep_id = google_keep_id
+        self.path = path
+        self.timestamp_updated = timestamp_updated
+
+        if not local_media:
+            self.local_media: Dict[str, LocalMedia] = {}
+        else:
+            self.local_media = local_media
 
 
 
 
-def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
-    index = {}
+def index_existing_files(directory: pathlib.Path) -> Dict[str, LocalNote]:
+    """
+    Scans the output folder looking for existing markdown files
+    and media files and builds an index by google_keep_id of those files
+    using the metadata in the markdown frontmatter and the filenames
+    of the media files.
+    """
+    index: Dict[str, LocalNote] = {}
 
 
     keep_notes = 0
     keep_notes = 0
     unknown_notes = 0
     unknown_notes = 0
@@ -182,23 +212,37 @@ def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
     media = 0
     media = 0
 
 
     for file in directory.rglob("*"):
     for file in directory.rglob("*"):
+        if not file.is_file():
+            continue
+
         # markdown file
         # markdown file
         if file.name.endswith(".md"):
         if file.name.endswith(".md"):
             try:
             try:
                 with open(file, "r") as f:
                 with open(file, "r") as f:
                     fm = frontmatter.load(f)
                     fm = frontmatter.load(f)
 
 
-                    info = MarkdownFile(
-                        file,
-                        fm.metadata.get("timestamps").get("updated"),
-                        fm.metadata.get("google_keep_id"),
-                    )
+                    google_keep_id: str = fm.metadata.get("google_keep_id")
+                    if google_keep_id:
+                        if google_keep_id in index and index[google_keep_id].path:
+                            click.echo(
+                                f"Same Google Keep ID {google_keep_id} in multiple files:\n"
+                                f"    {file}\n"
+                                f"    {index[google_keep_id].path}\n"
+                                f"Only the last file will be updated."
+                            )
 
 
-                    if info.google_keep_id:
                         keep_notes += 1
                         keep_notes += 1
-                        index[info.google_keep_id] = info
+                        index.setdefault(google_keep_id, LocalNote(google_keep_id))
+
+                        updated: datetime.datetime = datetime.datetime.fromtimestamp(
+                            fm.metadata.get("timestamps", {}).get("updated")
+                        )
+
+                        index[google_keep_id].timestamp_updated = updated
+                        index[google_keep_id].path = file
                     else:
                     else:
                         unknown_notes += 1
                         unknown_notes += 1
+
             except IOError as ex:
             except IOError as ex:
                 errors = 0
                 errors = 0
                 click.echo(
                 click.echo(
@@ -209,7 +253,14 @@ def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
         # media file
         # media file
         else:
         else:
             media += 1
             media += 1
-            click.echo(f"Ignoring media file [{file}]")
+
+            google_keep_id = file.parent.name
+            media_id = ".".join(file.name.split(".")[0:2])
+
+            index.setdefault(google_keep_id, LocalNote(google_keep_id))
+            index[google_keep_id].local_media[media_id] = LocalMedia(
+                file, google_keep_id, media_id
+            )
 
 
     click.echo(
     click.echo(
         f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
         f"Indexed local files: {keep_notes} Google Keep notes, {unknown_notes} unknown markdown files, {media} media files, {errors} errors"
@@ -218,12 +269,15 @@ def index_existing_files(directory: pathlib.Path) -> Dict[str, MarkdownFile]:
     return index
     return index
 
 
 
 
-def try_rename_note(note: MarkdownFile, target_file: pathlib.Path) -> pathlib.Path:
+def try_rename_note(note: LocalNote, target_file: pathlib.Path) -> pathlib.Path:
     """
     """
     Attempts to rename an existing note to the new canonical filename,
     Attempts to rename an existing note to the new canonical filename,
     but accepts failures to rename. Returns the path the note now exists
     but accepts failures to rename. Returns the path the note now exists
     in, either the old path or the new renamed path.
     in, either the old path or the new renamed path.
     """
     """
+    if not note.path:
+        return target_file
+
     click.echo(f"Renaming [{note.path}] to [{target_file}]")
     click.echo(f"Renaming [{note.path}] to [{target_file}]")
 
 
     try:
     try:
@@ -238,7 +292,7 @@ def build_note_unique_path(
     notepath: pathlib.Path,
     notepath: pathlib.Path,
     note: gkeepapi._node.Note,
     note: gkeepapi._node.Note,
     date_format: str,
     date_format: str,
-    local_index: Dict[str, MarkdownFile],
+    local_index: Dict[str, LocalNote],
 ) -> pathlib.Path:
 ) -> pathlib.Path:
     title = note.title.strip()
     title = note.title.strip()
     if not len(title):
     if not len(title):
@@ -248,10 +302,13 @@ def build_note_unique_path(
     filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
     filename = f'{sanitize_filename(f"{date_str} - " + title,max_len=135)}.md'
     target_path = notepath / filename
     target_path = notepath / filename
 
 
-    if note.id in local_index:
+    local_note = local_index.get(note.id)
+    local_path = local_note.path if local_note else None
+
+    if local_path:
         # if the note filename matches the current filename for that note, then we're good
         # 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 local_path == target_path:
+            return local_path
 
 
         # if re-naming would result in having to de-dupe the target filename, keep the
         # 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
         # exising filename - initial pass at fixing this just resulted in bouncing between
@@ -260,7 +317,7 @@ def build_note_unique_path(
             click.echo(
             click.echo(
                 f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
                 f"Note {note.id} will not be renamed. Target file [{target_path}] exists."
             )
             )
-            return local_index[note.id].path
+            return local_path
 
 
     # otherwise, if the file already exists avoid overwriting it
     # otherwise, if the file already exists avoid overwriting it
     # put the unique note ID and an incrementing index at the end of the filename
     # put the unique note ID and an incrementing index at the end of the filename
@@ -273,6 +330,77 @@ def build_note_unique_path(
     return target_path
     return target_path
 
 
 
 
+def delete_local_only_files(
+    local_index: Dict[str, LocalNote],
+    keep_notes: Dict[str, List[gkeepapi.node.Note]],
+    delete_local: bool,
+) -> Tuple[int, int]:
+    """
+    Checks the local index for any notes or media that exist only locally
+    and were not returned in the Google Keep API call.
+    """
+    deleted_notes, deleted_media = 0, 0
+
+    local_only_note_ids = set(local_index.keys()).difference(set(keep_notes.keys()))
+
+    if local_only_note_ids:
+        if not delete_local:
+            click.echo(
+                f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
+            )
+        else:
+            click.echo(
+                f"{len(local_only_note_ids)} notes exist locally, but not in Google Keep. Trashing local files."
+            )
+            for note_id in local_only_note_ids:
+                note_path = local_index[note_id].path
+                deleted_notes += 1
+                if note_path:
+                    click.echo(
+                        f"    Deleting unknown local note [{note_id}] file [{local_index[note_id].path}]"
+                    )
+                    note_path.unlink()
+
+    local_only_media: Set[Tuple[str, str]] = set(
+        [
+            (local_media.google_keep_note_id, local_media.google_keep_media_id)
+            for local_note in local_index.values()
+            for local_media in local_note.local_media.values()
+            if local_media.google_keep_note_id and local_media.google_keep_media_id
+        ]
+    )
+
+    notes: ValuesView[gkeepapi._node.Note] = keep_notes.values()
+    keep_media: Set[Tuple[str, str]] = set(
+        [
+            (keep_note.id, keep_media.id)
+            for keep_note in notes
+            for keep_media in all_note_media(keep_note)
+        ]
+    )
+
+    local_only_media_ids = local_only_media.difference(keep_media)
+    if not local_only_media_ids:
+        return (deleted_notes, 0)
+
+    if not delete_local:
+        click.echo(
+            f"{len(local_only_note_ids)} media files exist locally, but not in Google Keep. Add argument [--delete-local] to delete."
+        )
+        return (deleted_notes, 0)
+
+    for (note_id, media_id) in local_only_media_ids:
+        media = local_index[note_id].local_media[media_id]
+
+        click.echo(
+            f"    Deleting media [{media_id}] for note [{note_id}] file [{media.path}]"
+        )
+        media.path.unlink()
+        deleted_media += 1
+
+    return (deleted_notes, deleted_media)
+
+
 @click.command(
 @click.command(
     context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
     context_settings={"max_content_width": 120, "help_option_names": ["-h", "--help"]}
 )
 )
@@ -367,40 +495,39 @@ def main(
     click.echo("Indexing remote notes.")
     click.echo("Indexing remote notes.")
     keep_notes = dict([(note.id, note) for note in keep.all()])
     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()
+    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.TopLevelNode
-        if note.id in local_index:
-            click.echo(f"Updating existing file for note {note.id}")
+    for note in keep_notes.values():  # type: gkeepapi._node.Note
+        local_note = local_index.get(note.id)
+        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}")
         else:
         else:
             click.echo(f"Downloading new note {note.id}")
             click.echo(f"Downloading new note {note.id}")
+            new_notes += 1
 
 
         target_path = build_note_unique_path(notepath, note, date_format, local_index)
         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:
+        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)
                 target_path = try_rename_note(local_index[note.id], target_path)
             else:
             else:
-                target_path = local_index[note.id].path
+                target_path = local_path
 
 
-        images = download_images(keep, note, mediapath, skip_existing_media)
+        images, downloaded = download_images(keep, note, mediapath, skip_existing_media)
         markdown = build_markdown(note, images)
         markdown = build_markdown(note, images)
 
 
+        downloaded_media += downloaded
+
         with target_path.open("wb+") as f:
         with target_path.open("wb+") as f:
             if header:
             if header:
                 fmatter = build_frontmatter(note, markdown)
                 fmatter = build_frontmatter(note, markdown)
@@ -408,7 +535,11 @@ def main(
             else:
             else:
                 f.write(markdown.encode("utf-8"))
                 f.write(markdown.encode("utf-8"))
 
 
-    click.echo("Done.")
+    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")
 
 
 
 
 if __name__ == "__main__":
 if __name__ == "__main__":