File size: 14,441 Bytes
6d58347
3bd2b2f
ee59f53
3bd2b2f
1022bf0
3bd2b2f
6d58347
3bd2b2f
 
 
 
6d58347
 
3bd2b2f
1022bf0
3bd2b2f
 
6d58347
 
 
 
 
 
2e07db0
6d58347
2e07db0
 
6d58347
 
2e07db0
 
 
 
 
6d58347
 
 
 
2e07db0
6d58347
2e07db0
6d58347
2e07db0
6d58347
2e07db0
3bd2b2f
6d58347
2e07db0
 
 
 
6d58347
 
3bd2b2f
6d58347
 
 
 
 
3bd2b2f
6d58347
 
3bd2b2f
 
 
6d58347
 
 
3bd2b2f
6d58347
3bd2b2f
6d58347
3bd2b2f
 
2e07db0
 
 
6d58347
3bd2b2f
1240a6f
3bd2b2f
5caf29a
 
3bd2b2f
6d58347
 
3bd2b2f
6d58347
 
 
3bd2b2f
1240a6f
6d58347
5caf29a
6d58347
 
 
3bd2b2f
 
 
6d58347
 
 
 
 
 
 
1240a6f
6d58347
 
 
 
 
 
 
 
3bd2b2f
1022bf0
6d58347
1022bf0
6d9f8cb
5caf29a
6d58347
3bd2b2f
 
6d58347
 
 
1240a6f
6d58347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2e07db0
1240a6f
 
 
 
 
 
 
6d58347
 
 
 
 
3bd2b2f
6d58347
2e07db0
 
6d58347
 
1240a6f
2e07db0
6d58347
1240a6f
6d58347
1240a6f
6d58347
 
1240a6f
6d58347
 
 
3bd2b2f
6d58347
2e07db0
6d58347
 
3bd2b2f
2e07db0
6d58347
 
 
6d9f8cb
6d58347
3bd2b2f
6d58347
2e07db0
6d58347
 
 
 
 
1240a6f
 
6d58347
 
1022bf0
6d58347
3bd2b2f
6d58347
ee59f53
1240a6f
 
6d58347
 
 
 
 
2e07db0
6d58347
1240a6f
6d58347
 
 
 
6d9f8cb
6d58347
 
 
1240a6f
 
6d9f8cb
6d58347
 
 
 
 
5caf29a
2e07db0
6d58347
 
1240a6f
6d58347
ee59f53
2e07db0
6d58347
 
1240a6f
6d58347
1240a6f
 
6d58347
2e07db0
6d58347
1240a6f
6d58347
 
ee59f53
6d58347
1240a6f
 
6d58347
 
2e07db0
1240a6f
 
 
6d58347
1240a6f
6d58347
1240a6f
6d58347
1240a6f
 
6d58347
 
 
1240a6f
 
6d58347
1240a6f
 
6d58347
1240a6f
ee59f53
6d58347
 
 
 
 
 
 
 
 
3bd2b2f
 
6d58347
3bd2b2f
6d58347
 
3bd2b2f
 
6d58347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
3bd2b2f
5caf29a
6d58347
1240a6f
6d58347
3bd2b2f
6d58347
 
 
 
 
 
b90ddd5
6d58347
b90ddd5
 
 
2e07db0
b90ddd5
 
1240a6f
2e07db0
b90ddd5
 
6d58347
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
# -*- coding: utf-8 -*-
import os
import re
import time
import json
import tempfile
from typing import Optional, Tuple, List, Any

import matplotlib.pyplot as plt
import gradio as gr

# --- 引入 Google API 相關套件 ---
from googleapiclient.discovery import build, Resource
from googleapiclient.http import MediaIoBaseUpload
from googleapiclient.errors import HttpError
from google.oauth2.service_account import Credentials

# ====== CONFIGURATION ======
DEFAULT_EXTENT = (119.0, 123.5, 21.5, 25.5)  # 預設地圖範圍 (台灣)
SCOPES = ("https://www.googleapis.com/auth/drive.file",)  # Google Drive API 權限範圍,僅限於本應用程式建立的檔案
DEFAULT_FOLDER_URL = "https://drive.google.com/drive/folders/1B7D4Z6vV_G30nIrBr8qFsgNA4l2G-dRo"  # 預設上傳的資料夾 (可以是你的共用雲端硬碟資料夾)
MIMETYPE_PNG = "image/png"
MIMETYPE_FOLDER = "application/vnd.google-apps.folder"

# ====== UTILITY FUNCTIONS ======
def clean_folder_id(folder_input: str) -> Optional[str]:
    """
    從 Google Drive 資料夾的 URL 或純 ID 中,提取出乾淨的 folder ID。
    能自動處理常見的複製貼上錯誤,如多餘的標點符號。
    """
    if not folder_input:
        return None
    raw = folder_input.strip()

    # 優先從 URL 格式中擷取 ID (例如 .../folders/<ID>)
    match = re.search(r"/folders/([A-Za-z0-9_-]+)", raw)
    if match:
        raw = match.group(1)

    # 移除 URL 的 query string (例如 ?usp=sharing)
    raw = raw.split("?")[0]
    # 移除頭尾的引號
    raw = raw.strip().strip('"\'')
    # 移除使用者可能不小心複製到的結尾標點符號
    raw = raw.rstrip(" .。;;,,!!??、/\\』」)》)】]}")

    # 最終驗證 ID 格式是否合法 (Google Drive ID 通常是長度超過 20 的英數混合字串)
    return raw if re.fullmatch(r"[A-Za-z0-9_-]{20,}", raw) else None


def parse_points(csv_text: str) -> List[Tuple[float, float]]:
    """從文字輸入中解析出經緯度座標點列表。"""
    points: List[Tuple[float, float]] = []
    if not csv_text:
        return points
    for line in csv_text.strip().splitlines():
        line = line.strip()
        # 跳過空行或註解行
        if not line or line.startswith("#"):
            continue
        # 將 tab 轉換為逗號,並以逗號分割
        parts = [p.strip() for p in line.replace("\t", ",").split(",")]
        if len(parts) < 2:
            continue
        try:
            # 嘗試轉換為浮點數
            lon, lat = float(parts[0]), float(parts[1])
            points.append((lon, lat))
        except ValueError:
            # 轉換失敗則忽略此行
            continue
    return points


def make_figure(points: List[Tuple[float, float]], title: str,
                extent: Tuple[float, float, float, float] = DEFAULT_EXTENT,
                dpi: int = 150) -> str:
    """使用 Matplotlib 繪製地圖並存成暫存圖檔。"""
    min_lon, max_lon, min_lat, max_lat = extent
    fig, ax = plt.subplots(figsize=(6.5, 6.5), dpi=dpi)
    ax.set_title(title or "Map Plot", pad=10)
    ax.set_xlabel("Longitude"); ax.set_ylabel("Latitude")
    ax.set_xlim(min_lon, max_lon); ax.set_ylim(min_lat, max_lat)
    ax.grid(True, linestyle=":", linewidth=0.8)

    # 繪製座標點
    if points:
        xs = [p[0] for p in points]
        ys = [p[1] for p in points]
        ax.scatter(xs, ys, s=60, marker="*", label="Input points", zorder=10)
        ax.legend(loc="upper right")

    # 儲存圖片到系統暫存目錄
    out_path = os.path.join(tempfile.gettempdir(), f"map_{int(time.time())}.png")
    fig.tight_layout()
    fig.savefig(out_path)
    plt.close(fig)  # 關閉圖表以釋放記憶體,在 Web App 中很重要
    return out_path


def drive_service_from_secret() -> Tuple[Optional[Resource], Optional[str], Optional[str]]:
    """
    從環境變數讀取服務帳號 JSON,並建立 Google Drive API 服務物件。
    """
    raw_secret = os.environ.get("SERVICE_ACCOUNT_JSON", "").strip()
    if not raw_secret:
        return None, None, "在 Secrets 中找不到 `SERVICE_ACCOUNT_JSON`。"
    try:
        info = json.loads(raw_secret)
    except json.JSONDecodeError as e:
        return None, None, f"`SERVICE_ACCOUNT_JSON` 格式錯誤,不是有效的 JSON: {e}"

    client_email = info.get("client_email")
    if not client_email:
        return None, None, "`SERVICE_ACCOUNT_JSON` 中缺少 `client_email` 欄位。"

    try:
        creds = Credentials.from_service_account_info(info, scopes=SCOPES)
        # cache_discovery=False 在無伺服器或容器環境中是個好習慣,可避免快取問題
        service = build("drive", "v3", credentials=creds, cache_discovery=False)
        return service, client_email, None
    except Exception as e:
        return None, None, f"建立 Drive 服務時發生錯誤: {e}"


def upload_png_to_drive(local_png_path: str, service: Resource, folder_id: Optional[str], make_public: bool) -> Tuple[str, str]:
    """上傳本地圖片檔案到指定的 Google Drive 資料夾。"""
    file_metadata = {"name": os.path.basename(local_png_path)}
    if folder_id:
        file_metadata["parents"] = [folder_id]

    # 準備上傳的媒體內容
    with open(local_png_path, "rb") as f:
        media = MediaIoBaseUpload(f, mimetype=MIMETYPE_PNG)
        # 執行上傳請求
        # supportsAllDrives=True 是關鍵,讓服務帳號可以寫入共用雲端硬碟
        created_file = (
            service.files()
            .create(body=file_metadata, media_body=media,
                    fields="id, webViewLink",
                    supportsAllDrives=True)
            .execute()
        )

    file_id = created_file.get("id", "")
    web_view_link = created_file.get("webViewLink", "")

    # 如果使用者勾選了「公開」,則設定檔案權限
    if make_public and file_id:
        try:
            service.permissions().create(
                fileId=file_id,
                body={"role": "reader", "type": "anyone"},
                supportsAllDrives=True,
            ).execute()
        except HttpError as e:
            # 即使設定權限失敗,也不中斷流程,僅在連結後附加錯誤訊息
            web_view_link += f"\n(但設定公開連結失敗: {e})"

    return file_id, web_view_link


# ====== GRADIO HANDLER FUNCTIONS ======
def generate_and_upload(points_text, title, min_lon, max_lon, min_lat, max_lat,
                        folder_input, make_public):
    """Gradio 按鈕點擊後的主要處理函式。"""
    # 1. 產生本地圖片檔
    try:
        pts = parse_points(points_text)
        png_path = make_figure(pts, title, extent=(min_lon, max_lon, min_lat, max_lat))
    except Exception as e:
        return None, f"❌ 繪圖失敗: {e}"

    # 2. 建立 Google Drive 服務
    service, client_email, err_msg = drive_service_from_secret()
    if service is None:
        return png_path, (f"⚠️ **Google Drive 未設定或設定錯誤**\n"
                          f"請確認您已在 Hugging Face Space 的 Settings → Secrets 中新增了 `SERVICE_ACCOUNT_JSON`。\n"
                          f"詳細錯誤: {err_msg}")

    # 3. 決定要使用的 Folder ID (優先順序: UI 輸入 > 環境變數 > 程式碼預設值)
    env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip()
    raw_folder_input = folder_input or env_folder or DEFAULT_FOLDER_URL
    folder_id = clean_folder_id(raw_folder_input)

    if not folder_id:
        return png_path, (f"❌ **無效的 Google Drive 資料夾網址或 ID**\n"
                          f"請貼上整個資料夾網址,或是正確的 ID。\n"
                          f"收到的內容: `{raw_folder_input}`")

    # 4. 上傳檔案
    try:
        file_id, view_link = upload_png_to_drive(png_path, service, folder_id, make_public)
        msg = [
            "✅ **上傳成功!**",
            f"• **服務帳號**: `{client_email}`",
            f"• **目標資料夾 ID**: `{folder_id}`",
            f"• **檔案 ID**: `{file_id}`",
            f"• **檔案連結**: {view_link}"
        ]
        if not make_public:
            msg.append("\n> _提示: 未勾選公開,檔案將繼承資料夾的權限設定。_")
        return png_path, "\n".join(msg)
    except HttpError as e:
        return png_path, f"❌ **上傳失敗 (HttpError)**: {e}"
    except Exception as e:
        return png_path, f"❌ **上傳失敗 (未知錯誤)**: {e}"


def check_config():
    """檢查環境變數與 API 連線狀態。"""
    service, client_email, err_msg = drive_service_from_secret()
    if service is None:
        return f"❌ **服務建立失敗**: {err_msg}"

    env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip()
    folder_id = clean_folder_id(env_folder or DEFAULT_FOLDER_URL)

    output = [
        f"✅ **服務帳號已載入**: `{client_email}`",
        f"➡️ **將使用的資料夾 ID**: `{folder_id}` (來自: {'Secret' if env_folder else '預設值'})"
    ]

    if folder_id:
        try:
            # 嘗試取得資料夾名稱來驗證 ID 是否有效以及權限是否足夠
            meta = service.files().get(
                fileId=folder_id, fields="id, name", supportsAllDrives=True
            ).execute()
            output.append(f"✅ **目標資料夾名稱**: **{meta.get('name')}**")
        except HttpError as e:
            output.append(f"⚠️ **無法讀取資料夾資訊**: {e}\n> _請檢查服務帳號是否有此資料夾的「檢視者」或更高權限。_")
    return "\n".join(output)


def list_folder(folder_input):
    """列出指定資料夾內的檔案。"""
    service, client_email, err_msg = drive_service_from_secret()
    if service is None:
        return f"❌ **服務建立失敗**: {err_msg}"

    env_folder = os.environ.get("DRIVE_FOLDER_ID", "").strip()
    raw_folder_input = folder_input or env_folder or DEFAULT_FOLDER_URL
    folder_id = clean_folder_id(raw_folder_input)
    if not folder_id:
        return "❌ **無效的 Google Drive 資料夾網址或 ID。**"

    try:
        # 驗證目標是否為資料夾
        meta = service.files().get(
            fileId=folder_id, fields="name, mimeType", supportsAllDrives=True
        ).execute()
        if meta.get("mimeType") != MIMETYPE_FOLDER:
            return f"⚠️ **目標不是資料夾**: `{meta.get('name')}` (類型: {meta.get('mimeType')})"
    except HttpError as e:
        return f"❌ **取得資料夾資訊失敗**: {e}\n> _請檢查 ID 是否正確,以及服務帳號是否有權限存取。_"

    try:
        # 列出資料夾內容
        results = service.files().list(
            q=f"'{folder_id}' in parents and trashed=false",
            fields="files(id, name, mimeType, webViewLink)",
            pageSize=50,
            supportsAllDrives=True,
            corpora="allDrives" # corpora='allDrives' 可同時搜尋個人、共用雲端硬碟
        ).execute()
        files = results.get("files", [])
    except HttpError as e:
        return f"❌ **列出檔案失敗**: {e}"

    lines = [
        f"✅ **服務帳號**: `{client_email}`",
        f"✅ **資料夾**: **{meta.get('name')}** (`{folder_id}`)",
        "\n--- 內容 (最多 50 筆) ---"
    ]
    if not files:
        lines.append("(此資料夾為空,或服務帳號無權限看見任何檔案)")
    else:
        for f in files:
            lines.append(f"- **{f['name']}** (`{f['mimeType']}`) - [開啟連結]({f['webViewLink']})")
    return "\n".join(lines)

# ====== GRADIO UI DEFINITION ======
with gr.Blocks(title="地圖產生器 → Google Drive", theme=gr.themes.Soft()) as demo:
    gr.Markdown(f"""
    # 🗺️ 地圖產生器 → Google Drive 上傳工具
    這個工具會根據輸入的經緯度座標點產生一張地圖圖片,並將其上傳到指定的 Google Drive 資料夾。
    - **預設上傳資料夾**: `{DEFAULT_FOLDER_URL}`
    - 可在下方欄位輸入新的資料夾 URL/ID 來覆蓋預設值。
    - 也可在 Space 的 **Settings → Secrets** 中設定 `DRIVE_FOLDER_ID` 來改變預設值。
    """)

    with gr.Row():
        with gr.Column(scale=2):
            points_in = gr.Textbox(
                label="座標點 (每行一組 經度,緯度)",
                value="121.5,25.0\n120.9,24.5\n121.0,23.9\n# 這是註解,會被忽略",
                lines=8,
            )
            title_in = gr.Textbox(label="地圖標題", value="台灣座標點範例")

            with gr.Accordion("進階設定", open=False):
                folder_in = gr.Textbox(
                    label="(可選)覆蓋預設資料夾",
                    placeholder="貼上新的 Google Drive 資料夾 URL 或 純 ID",
                )
                make_public_in = gr.Checkbox(
                    label="建立公開分享連結 (權限: 擁有連結的任何人皆可檢視)", value=False
                )
                with gr.Row():
                    min_lon_in = gr.Number(label="最小經度", value=DEFAULT_EXTENT[0])
                    max_lon_in = gr.Number(label="最大經度", value=DEFAULT_EXTENT[1])
                    min_lat_in = gr.Number(label="最小緯度", value=DEFAULT_EXTENT[2])
                    max_lat_in = gr.Number(label="最大緯度", value=DEFAULT_EXTENT[3])

            with gr.Row():
                run_btn = gr.Button("🚀 產生圖片並上傳", variant="primary")
                list_btn = gr.Button("📄 列出資料夾內容")
                cfg_btn = gr.Button("🧪 檢查設定")

        with gr.Column(scale=3):
            out_image = gr.Image(label="預覽圖", type="filepath")
            out_msg = gr.Markdown(label="執行結果")
            with gr.Accordion("除錯與列表資訊", open=False):
                cfg_out = gr.Markdown(label="設定檢查結果")
                list_out = gr.Markdown(label="資料夾內容列表")

    # --- 按鈕事件綁定 ---
    run_btn.click(
        fn=generate_and_upload,
        inputs=[points_in, title_in, min_lon_in, max_lon_in, min_lat_in, max_lat_in,
                folder_in, make_public_in],
        outputs=[out_image, out_msg],
    )
    cfg_btn.click(fn=check_config, inputs=None, outputs=cfg_out)
    list_btn.click(fn=list_folder, inputs=folder_in, outputs=list_out)

if __name__ == "__main__":
    demo.launch()