LogSeq to Outlook Events Script

Overview of the Script
The LogSeq to Outlook Events Script automates the creation of Outlook calendar events from scheduled blocks in your LogSeq markdown files. In each run, it:

  1. Loads configuration (graph name, LogSeq directory, look-ahead window) from config.json (or prompts if missing) .

  2. Scans all .md files under the specified LogSeq directory for list items (- …) containing a SCHEDULED: <YYYY-MM-DD [HH:MM]> tag and a blockID:: ….
    You need to create a property named blockID within the scheduled event and set its value to the block reference number without parentheses.

  3. Deduplicates against:

    • Previously created events (tracked in outlook_blockids.json)
    • Existing Outlook events whose bodies contain a matching LogSeq blockID
  4. Creates new Outlook appointments for any future scheduled items not yet synced:

    • Sets a 60-minute duration with a 10-minute reminder
    • Writes the LogSeq blockID and a deep-link back into the Markdown source
  5. Generates a report of β€œorphan” Outlook events (those whose blockIDs no longer appear in your LogSeq files), saving it to outlook_orphans_report.txt.(for now not working :pensive_face:)

  6. Saves updated blockID data back to outlook_blockids.json and opens newly created events in Outlook for review.

Supported OS & Application

  • Operating System: Windows (relies on the pywin32 COM interface)
  • Calendar Program: Microsoft Outlook, via win32com.client

Startup Parameters to Customize
These are read from (or initially written to) config.json:

  • graph_name: your LogSeq graph identifier (used when constructing deep-links)
  • logseq_dir: full path to your local LogSeq vault (e.g., D:\LogSeq)
  • days_lookahead: how many days into the future to scan for and sync scheduled items (default: 365)
# Logseq to Outlook Events Sync Script
# Version 1.4.21 modified to open new events at the end

import os
import re
import json
from datetime import datetime, timedelta
import win32com.client

# --------------------------- Configuration Handling ---------------------------
SCRIPT_VERSION = "v1.4.21"
CONFIG_FILE = "config.json"

def load_config():
    print("πŸ”§ Loading configuration...")
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            return json.load(f)
    else:
        config = {
            "graph_name": input("Enter your LogSeq graph name: "),
            "logseq_dir": input("Enter your LogSeq directory (e.g., D:\\LogSeq): "),
            "days_lookahead": int(input("Enter number of days to look ahead for events: ") or "365")
        }
        with open(CONFIG_FILE, "w", encoding="utf-8") as f:
            json.dump(config, f, indent=4)
        return config

config = load_config()
GRAPH_NAME = config["graph_name"]
LOGSEQ_DIR = config["logseq_dir"]
DAYS_LOOKAHEAD = config["days_lookahead"]

print(f"πŸš€ Starting LogSeq β†’ Outlook script | Version: {SCRIPT_VERSION}\n")

# --------------------------- Pattern Definitions ---------------------------
SCHEDULED_PATTERN = re.compile(r"SCHEDULED:\s*<(\d{4}-\d{2}-\d{2})(?: \w{3})?(?: (\d{2}:\d{2}))?>")
BLOCKID_PATTERN = re.compile(r"blockID::\s*([\w-]+)")

# --------------------------- File Paths ---------------------------
script_dir = os.path.dirname(os.path.abspath(__file__))
json_file_path = os.path.join(script_dir, "outlook_blockids.json")
report_file_path = os.path.join(script_dir, "outlook_orphans_report.txt")

# --------------------------- Outlook Setup ---------------------------
outlook = win32com.client.Dispatch("Outlook.Application")
namespace = outlook.GetNamespace("MAPI")

# --------------------------- Load Existing BlockIDs ---------------------------
if os.path.exists(json_file_path):
    with open(json_file_path, "r", encoding="utf-8") as f:
        saved_blockids_data = json.load(f)
else:
    saved_blockids_data = {}

if isinstance(saved_blockids_data, list):
    saved_blockids_data = {bid: "2000-01-01" for bid in saved_blockids_data}

now = datetime.now()
past_limit = now - timedelta(days=DAYS_LOOKAHEAD)
saved_blockids = {
    bid: ds for bid, ds in saved_blockids_data.items()
    if datetime.strptime(ds, "%Y-%m-%d") >= past_limit
}

print(f"πŸ” Loaded {len(saved_blockids)} saved blockIDs (after cleanup)\n")

# --------------------------- Read Outlook Events ---------------------------
def get_outlook_blockids():
    print("πŸ” Fetching existing Outlook events...")
    calendar = namespace.GetDefaultFolder(9)
    items = calendar.Items
    items.IncludeRecurrences = True
    items.Sort("[Start]")

    now = datetime.now()
    future_limit = now + timedelta(days=DAYS_LOOKAHEAD)
    restriction = (
        f"[Start] >= '{now.strftime('%m/%d/%Y')}' "
        f"AND [Start] <= '{future_limit.strftime('%m/%d/%Y')}'"
    )
    future_items = items.Restrict(restriction)

    blockids = {}
    for appt in future_items:
        try:
            body = getattr(appt, "Body", "")
            match = re.search(r"LogSeq blockID: ([\w-]+)", body)
            if match:
                bid = match.group(1)
                blockids[bid] = (appt.Start.Format("%Y-%m-%d %H:%M"), appt.EntryID)
        except Exception as e:
            print(f"⚠️ Error reading Outlook event: {e}")
    return blockids

outlook_blockids = get_outlook_blockids()
logseq_blockids = set()
# Now let's save entry_id too
created_events_this_run = []  # list of tuples: (title, date, time, block_id, entry_id)

# --------------------------- Utility: Clean Markdown Block ---------------------------
def clean_block_text(text):
    print(f"πŸ›  Cleaning block text: {text[:30]}...")
    text = re.sub(r"^\s*-\s*", "", text)
    text = re.split(r"SCHEDULED:", text)[0]
    text = re.sub(r":LOGBOOK:.*?:END:", "", text, flags=re.DOTALL)
    text = re.sub(r"CLOCK:.*?\]", "", text, flags=re.DOTALL)
    text = re.sub(r"DEADLINE:\s*<.*?>", "", text)
    text = re.sub(r"logseq.order-list-type::\s*\w+", "", text)
    text = re.sub(r"\w+::\s*\S+", "", text)
    return text.strip()

# --------------------------- Create New Outlook Event ---------------------------
def create_event(title, scheduled_date, scheduled_time, block_id, source_file_path):
    print(f"πŸ†• Attempting to create event: {title} | {scheduled_date} {scheduled_time} | blockID: {block_id}")
    now = datetime.now()
    if block_id in saved_blockids or block_id in outlook_blockids:
        print(f"⏩ Skipping duplicate: {title} | blockID: {block_id}")
        return

    dt = datetime.strptime(f"{scheduled_date} {scheduled_time or '09:00'}", "%Y-%m-%d %H:%M")
    if dt <= now:
        print(f"⏩ Ignored (past): {dt}")
        return

    appt = outlook.CreateItem(1)
    appt.Start = dt
    appt.Duration = 60
    appt.ReminderSet = True
    appt.ReminderMinutesBeforeStart = 10
    appt.AllDayEvent = False
    appt.Subject = title

    logseq_link = f"logseq://graph/{GRAPH_NAME}?block-id={block_id}"
    appt.Body = (
        f"Automatically created from LogSeq on {now.strftime('%Y-%m-%d')} at {now.strftime('%H:%M')}\n\n"
        f"LogSeq blockID: {block_id}\n\n"
        f"Open in LogSeq: {logseq_link}\n"
        f"[To create the link: select the LogSeq link above > copy  > Ctrl+K > paste the link into the address field > confirm]\n\n"
        f"LogSeq MD source file: {source_file_path}\n"
        "_________________________________________________________________________________"
    )

    appt.Save()
    entry_id = appt.EntryID

    # I insert the link in the source file
    def add_outlook_link(filepath, block_id, entry_id, title):
        with open(filepath, "r", encoding="utf-8") as f:
            lines = f.readlines()
        for i, line in enumerate(lines):
            if block_id in line:
                html_link = (
                    f"\n\t- Link to Outlook event (entryID): "
                    f"<a href=\"outlook:{entry_id}\">{title}</a>"
                )
                lines.insert(i + 3, html_link)
                break
        with open(filepath, "w", encoding="utf-8") as f:
            f.writelines(lines)
        print(f"πŸ“ Added Outlook link under blockID {block_id} in {filepath}")

    add_outlook_link(source_file_path, block_id, entry_id, title)
    print(f"βœ” Created Outlook event for: {title} | blockID: {block_id}")

    # Creation register
    saved_blockids[block_id] = dt.strftime("%Y-%m-%d")
    created_events_this_run.append((title, scheduled_date, scheduled_time or '09:00', block_id, entry_id))

# --------------------------- Scan LogSeq Directory ---------------------------
print(f"πŸ” Starting scan of LogSeq directory: {LOGSEQ_DIR}")
for root, dirs, files in os.walk(LOGSEQ_DIR):
    dirs[:] = [d for d in dirs if d not in ['.recycle', 'bak', 'logseq']]
    for filename in files:
        if not filename.endswith(".md"):
            continue
        filepath = os.path.join(root, filename)
        print(f"πŸ” Scanning: {filepath}")
        with open(filepath, "r", encoding="utf-8") as f:
            lines = f.readlines()

        block_text = ""
        scheduled_date = None
        scheduled_time = None
        block_id = None

        for line in lines:
            m = BLOCKID_PATTERN.search(line)
            if m:
                block_id = m.group(1)
                logseq_blockids.add(block_id)

            if line.strip().startswith("- ") and len(line.strip()) > 1:
                if scheduled_date and block_id:
                    title = clean_block_text(block_text)
                    create_event(title, scheduled_date, scheduled_time, block_id, filepath)
                block_text = line.strip()
                scheduled_date = None
                scheduled_time = None
                block_id = None
            else:
                block_text += " " + line.strip()
                sm = SCHEDULED_PATTERN.search(line)
                if sm:
                    scheduled_date = sm.group(1)
                    scheduled_time = sm.group(2)

        # last block
        if scheduled_date and block_id:
            title = clean_block_text(block_text)
            create_event(title, scheduled_date, scheduled_time, block_id, filepath)

# --------------------------- Orphans & Save ---------------------------
orphans = {
    bid: details for bid, details in outlook_blockids.items()
    if bid not in logseq_blockids and
       bid not in [b[3] for b in created_events_this_run]
}
if orphans:
    with open(report_file_path, "w", encoding="utf-8") as report:
        report.write("Orphan Outlook events:\n")
        for bid, (ev_time, eid) in orphans.items():
            line = f" - blockID: {bid} | Date: {ev_time} | EntryID: {eid}\n"
            print(line.strip())
            report.write(line)
    print(f"\nπŸ“„ Orphan report saved to '{report_file_path}'")

with open(json_file_path, "w", encoding="utf-8") as f:
    json.dump(saved_blockids, f, indent=4)
print(f"\nπŸ“ƒ Saved {len(saved_blockids)} blockIDs to '{json_file_path}'")

# --------------------------- Summary & Open Events ---------------------------
print("βœ… Summary of events created in this run:")
if created_events_this_run:
    for title, date, time, bid, eid in created_events_this_run:
        print(f" - {title} | {date} {time} | blockID: {bid} | EntryID: {eid}")
    print("\nπŸ”— Opening newly created events in Outlook...")
    for _, _, _, _, eid in created_events_this_run:
        try:
            ev = namespace.GetItemFromID(eid)
            ev.Display()
        except Exception as e:
            print(f"⚠️ Could not open event {eid}: {e}")
else:
    print(" - No new events created")

if not orphans:
    print("\nℹ️ No orphan events found in Outlook.")

Thanks for sharing it @Harlock . I think this is pretty cool and could be useful.

I read through the code and tried running it today in a test graph with three events scheduled in the next two days, but could not make it work.

In case you are curious, this is the output I obtain. It runs without spewing any errors:

πŸ”§ Loading configuration...
πŸš€ Starting LogSeq β†’ Outlook script | Version: v1.4.21

πŸ” Loaded 0 saved blockIDs (after cleanup)

πŸ” Fetching existing Outlook events...
πŸ” Starting scan of LogSeq directory: C:\{redacted}
πŸ” Scanning: C:{redacted}
πŸ” Scanning: C:{redacted}
--- repeats the previous line scanning through all .md files in my test graph ---

πŸ“ƒ Saved 0 blockIDs to 'C:\{redacted}\outlook_blockids.json'
βœ… Summary of events created in this run:
 - No new events created

ℹ️ No orphan events found in Outlook.

You need to create a property named blockID within the scheduled event you want to sync with Outlook and set its value to the block reference number without parentheses.

1 Like

Thanks! Spot on ant it mostly works now.

It’s interesting. Thanks again for sharing it. If you don’t mind, I’ll fiddle with it a bit to see if I can adapt it to my workflow.