0

New or Updated File

by
Published today

Emits the files or folders created (or, with `events`, also modified; a folder counts as modified when something inside it changes) in a site's default document library, or in another of its libraries when `drive_id` is set since the last poll, optionally only under one `folder` path (e.g. `/Reports`). The first run records the drive's current state and emits nothing. Polls the drive's delta feed because Microsoft Graph push needs a change-notification subscription renewed every few weeks against a public endpoint, which belongs in a Windmill native trigger; run this as a Scheduled Poll until then. Requires Sites.Read.All (covered by the default Sites.ReadWrite.All).

Scriptยท trigger ms_sharepoint Verified

The script

Submitted by hugo989 Typescript (fetch-only)
Verified 5 hours ago
1
//native
2

3
import * as wmill from "windmill-client"
4

5
const graph = "https://graph.microsoft.com/v1.0"
6

7
export type DynSelect_site_id = string
8

9
export async function site_id(auth: RT.MsSharepoint) {
10
  const response = await fetch(
11
    `${graph}/sites?search=*&$select=id,displayName,webUrl&$top=200`,
12
    {
13
      headers: {
14
        Authorization: `Bearer ${auth.token}`,
15
        Accept: "application/json",
16
      },
17
    }
18
  )
19
  if (!response.ok) {
20
    throw new Error(`${response.status} ${await response.text()}`)
21
  }
22
  const { value } = (await response.json()) as {
23
    value: { id: string; displayName: string; webUrl: string }[]
24
  }
25
  return value.map((s) => ({
26
    value: s.id,
27
    label: `${s.displayName} (${s.webUrl})`,
28
  }))
29
}
30

31
export type DynSelect_drive_id = string
32

33
export async function drive_id(auth: RT.MsSharepoint, site_id: string) {
34
  const response = await fetch(
35
    `${graph}/sites/${site_id}/drives?$select=id,name`,
36
    {
37
      headers: {
38
        Authorization: `Bearer ${auth.token}`,
39
        Accept: "application/json",
40
      },
41
    }
42
  )
43
  if (!response.ok) {
44
    throw new Error(`${response.status} ${await response.text()}`)
45
  }
46
  const { value } = (await response.json()) as {
47
    value: { id: string; name: string }[]
48
  }
49
  return value.map((d) => ({ value: d.id, label: d.name }))
50
}
51

52
// The site's default document library, or the library given by drive_id
53
function driveUrl(site_id: string, drive_id: string | undefined) {
54
  return drive_id != null && drive_id !== ""
55
    ? `${graph}/drives/${encodeURIComponent(drive_id)}`
56
    : `${graph}/sites/${site_id}/drive`
57
}
58

59
// An item is addressed by its id, or by a path from the drive root starting with "/"
60
function ref(item: string | undefined) {
61
  if (item == null || item === "" || item === "/") return "root"
62
  if (!item.startsWith("/")) return `items/${encodeURIComponent(item)}`
63
  const path = item
64
    .replace(/\/+$/, "")
65
    .split("/")
66
    .map(encodeURIComponent)
67
    .join("/")
68
  return `root:${path}:`
69
}
70

71
/**
72
 * New or Updated File
73
 * Emits the files or folders created (or, with `events`, also modified; a folder counts as modified when something inside it changes) in a site's default document library, or in another of its libraries when `drive_id` is set since the last poll, optionally only under one `folder` path (e.g. `/Reports`). The first run records the drive's current state and emits nothing. Polls the drive's delta feed because Microsoft Graph push needs a change-notification subscription renewed every few weeks against a public endpoint, which belongs in a Windmill native trigger; run this as a Scheduled Poll until then. Requires Sites.Read.All (covered by the default Sites.ReadWrite.All).
74
 */
75
export async function main(
76
  auth: RT.MsSharepoint,
77
  site_id: DynSelect_site_id,
78
  events: "created" | "created_or_updated" = "created",
79
  kind: "files" | "folders" | "all" = "files",
80
  folder: string | undefined,
81
  drive_id: DynSelect_drive_id | undefined
82
) {
83
  const drive = driveUrl(site_id, drive_id)
84
  const state: { delta_link: string; since: string } | undefined =
85
    await wmill.getState()
86
  // Taken before the request so an item created while paging is not skipped next run
87
  const now = new Date().toISOString()
88
  const latest = `${drive}/root/delta?token=latest`
89

90
  // The feed can repeat an item: the last occurrence wins
91
  const items = new Map<string, { [key: string]: any }>()
92
  let deltaLink: string | undefined
93
  let expired = false
94
  let next: string | undefined = state?.delta_link ?? latest
95
  while (next) {
96
    const response = await fetch(next, {
97
      headers: {
98
        Authorization: `Bearer ${auth.token}`,
99
        Accept: "application/json",
100
      },
101
    })
102
    // 410 Gone: the stored delta link is too old, so start over from the current state
103
    if (response.status === 410 && !expired) {
104
      expired = true
105
      items.clear()
106
      next = latest
107
      continue
108
    }
109
    if (!response.ok) {
110
      throw new Error(`${response.status} ${await response.text()}`)
111
    }
112
    const page = (await response.json()) as {
113
      value: { [key: string]: any }[]
114
      "@odata.nextLink"?: string
115
      "@odata.deltaLink"?: string
116
    }
117
    for (const item of page.value) items.set(item.id, item)
118
    next = page["@odata.nextLink"]
119
    deltaLink = page["@odata.deltaLink"] ?? deltaLink
120
  }
121
  await wmill.setState({ delta_link: deltaLink, since: now })
122

123
  // First run (or an expired link): only record where the feed stands
124
  if (!state?.delta_link || expired) return []
125

126
  let under = ""
127
  let underId: string | undefined
128
  if (folder != null && folder !== "" && folder !== "/") {
129
    under = folder.replace(/\/+$/, "")
130
    const response = await fetch(`${drive}/${ref(under)}?$select=id`, {
131
      headers: {
132
        Authorization: `Bearer ${auth.token}`,
133
        Accept: "application/json",
134
      },
135
    })
136
    if (!response.ok) {
137
      throw new Error(`${response.status} ${await response.text()}`)
138
    }
139
    underId = ((await response.json()) as { id: string }).id
140
  }
141

142
  return [...items.values()].filter((item) => {
143
    if (item.deleted || item.root) return false
144
    if (kind === "files" && !item.file) return false
145
    if (kind === "folders" && !item.folder) return false
146
    if (
147
      events === "created" &&
148
      Date.parse(item.createdDateTime) < Date.parse(state.since)
149
    ) {
150
      return false
151
    }
152
    if (under === "") return true
153
    // parentReference.path looks like /drive/root:/Reports/2026, but the feed
154
    // may leave it out: direct children are then still matched by parent id
155
    const parent = decodeURIComponent(item.parentReference?.path ?? "").split(
156
      "root:"
157
    )[1]
158
    return (
159
      item.parentReference?.id === underId ||
160
      parent === under ||
161
      (parent ?? "").startsWith(under + "/")
162
    )
163
  })
164
}
165