dogsheep / apple-notes-to-sqlite

Export Apple Notes to SQLite
Apache License 2.0
184 stars 8 forks source link

Folder support #7

Closed simonw closed 1 year ago

simonw commented 1 year ago

Notes can live in folders. These relationships should be exported too.

simonw commented 1 year ago

From the Script Editor library docs:

A note has a:

  • container (folder), r/o) : the folder of the note

Here's what a folder looks like:

folder n : a folder containing notes elements:

  • contains folders, notes; contained by application, accounts, folders.

properties:

  • name (text) : the name of the folder
  • id (text, r/o) : the unique identifier of the folder
  • shared (boolean, r/o) : Is the folder shared?
  • container (account or folder, r/o) : the container of the folder
simonw commented 1 year ago

So it looks like folders can be hierarchical?

simonw commented 1 year ago

I used ChatGPT to write this:

osascript -e 'tell application "Notes"
    set allFolders to folders
    repeat with aFolder in allFolders
        set folderId to id of aFolder
        set folderName to name of aFolder
        set folderContainer to container of aFolder
        set folderContainerName to name of folderContainer
        log "Folder ID: " & folderId
        log "Folder Name: " & folderName
        log "Folder Container: " & folderContainerName
        log " "
        --check for nested folders
        if count of folders of aFolder > 0 then
            set nestedFolders to folders of aFolder
            repeat with aNestedFolder in nestedFolders
                set nestedFolderId to id of aNestedFolder
                set nestedFolderName to name of aNestedFolder
                set nestedFolderContainer to container of aNestedFolder
                set nestedFolderContainerName to name of nestedFolderContainer
                log "    Nested Folder ID: " & nestedFolderId
                log "    Nested Folder Name: " & nestedFolderName
                log "    Nested Folder Container: " & nestedFolderContainerName
                log " "
            end repeat
        end if
    end repeat
end tell
'

Which for my account output this:

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p6113
Folder Name: Blog posts
Folder Container: iCloud

    Nested Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p7995
    Nested Folder Name: Nested inside blog posts
    Nested Folder Container: Blog posts

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p698
Folder Name: JSK
Folder Container: iCloud

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p7995
Folder Name: Nested inside blog posts
Folder Container: Blog posts

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p3526
Folder Name: New Folder
Folder Container: iCloud

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p3839
Folder Name: New Folder 1
Folder Container: iCloud

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p2
Folder Name: Notes
Folder Container: iCloud

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p6059
Folder Name: Quick Notes
Folder Container: iCloud

Folder ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p7283
Folder Name: UK Christmas 2022
Folder Container: iCloud

So I think the correct approach here is to run code at the start to list all of the folders (no need to do fancy recursion though, just a flat list with the parent containers is enough) and create a model of that hierarchy in SQLite.

Then when I import notes I can foreign key reference them back to their containing folder.

I'm tempted to use rowid for the foreign keys because the official IDs are pretty long.

simonw commented 1 year ago

Created through several rounds with ChatGPT (including hints like "rewrite that using setdefault()"):

def topological_sort(nodes):
    children = {}
    for node in nodes:
        parent_id = node["parent"]
        if parent_id is not None:
            children.setdefault(parent_id, []).append(node)

    def traverse(node, result):
        result.append(node)
        if node["id"] in children:
            for child in children[node["id"]]:
                traverse(child, result)

    sorted_data = []

    for node in nodes:
        if node["parent"] is None:
            traverse(node, sorted_data)

    return sorted_data
simonw commented 1 year ago

Improved script:

osascript -e 'tell application "Notes"
    set allFolders to folders
    repeat with aFolder in allFolders
        set folderId to id of aFolder
        set folderName to name of aFolder
        set folderContainer to container of aFolder
        if class of folderContainer is folder then
            set folderContainerId to id of folderContainer
        else
            set folderContainerId to ""
        end if
        log "ID: " & folderId
        log "Name: " & folderName
        log "Container: " & folderContainerId
        log " "
    end repeat
end tell
'
ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p6113
Name: Blog posts
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p698
Name: JSK
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p7995
Name: Nested inside blog posts
Container: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p6113

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p3526
Name: New Folder
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p3839
Name: New Folder 1
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p2
Name: Notes
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p6059
Name: Quick Notes
Container: 

ID: x-coredata://D2D50498-BBD1-4097-B122-D15ABD32BDEC/ICFolder/p7283
Name: UK Christmas 2022
Container: 

I filtered out things where the parent was an account and not a folder using if class of folderContainer is folder then.

simonw commented 1 year ago

My folders table will have: