up
This commit is contained in:
parent
9bbc344137
commit
67cc3da6c8
@ -1,7 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -c get-site-config.js)"
|
||||
"Bash(node -c get-site-config.js)",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_492045.json\")",
|
||||
"Bash(echo \"exit:$?\")",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_475384.json\")",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_492169.json\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
11
ClaudePleasanter/助っ人クラウド/.claude/settings.json
Normal file
11
ClaudePleasanter/助っ人クラウド/.claude/settings.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -c get-site-config.js)",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_492045.json\")",
|
||||
"Bash(echo \"exit:$?\")",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_475384.json\")",
|
||||
"Bash(node get-site-config.js \"C:\\\\Users\\\\KA5AC~1.NOG\\\\AppData\\\\Local\\\\Temp\\\\claude\\\\c--Users-k-nogi--GitHub-ken-nogi-ClaudePleasanter-MSS-------\\\\4612346a-d27e-4254-ab27-863e0d44e52a\\\\scratchpad\\\\config_492169.json\")"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,223 @@
|
||||
---
|
||||
name: pleasanter-site-spec
|
||||
description: Pleasanterのサイト設定JSON(get-site-config.js / api-site-get の getsite レスポンス)から、SiteSettingsを読み解いたMarkdown仕様書を生成し、Scripts/Styles/ServerScripts/Processesの抽出・編集・反映(extract-site-config.js / build-desired-config.js / apply-site-config.js)を行う。また、技術仕様書とは別に業務担当者向けの概要書(docs/site-{SiteId}_overview.md)を作成し、md-to-pdf.jsでPDF化する。「仕様書を作って」「サイト仕様書」「SiteSettingsをドキュメント化」「スクリプトを抜き出して」「プロセス設定を直して反映して」「概要書を作って」「PDFにして」「site-spec」等の依頼で使う。
|
||||
---
|
||||
|
||||
# Pleasanter サイト仕様書生成
|
||||
|
||||
`./configs/site-{SiteId}_latest.json`(`getsite` APIレスポンス、`Response.Data` 配下にサイト情報)を読み解き、
|
||||
`./configs/site-{SiteId}_spec.md` として日本語のMarkdown仕様書を出力する。
|
||||
|
||||
## 入力の特定
|
||||
|
||||
1. ユーザーがJSONファイルパスやSiteIdを指定した場合はそれを使う。
|
||||
2. 指定がなければ `./configs/site-*_latest.json` を探す。複数ある場合はユーザーに確認する。
|
||||
3. `./configs/site-{SiteId}_latest.json` が無ければ、先に `node get-site-config.js` の実行を提案する
|
||||
(エンドポイントは `/api/items/{SiteId}/getsite` を使うこと。`/api/items/{SiteId}/get` はアイテム一覧取得APIであり別物なので注意)。
|
||||
|
||||
## 出力ファイル
|
||||
|
||||
`./configs/site-{SiteId}_spec.md`(既存があれば上書きしてよい)。
|
||||
|
||||
## 仕様書の構成(この順番・見出しレベルを踏襲する)
|
||||
|
||||
```markdown
|
||||
# サイト仕様書:{Title}(SiteId: {SiteId})
|
||||
|
||||
- 取得元データ: [site-{SiteId}_latest.json](./site-{SiteId}_latest.json)
|
||||
- 取得日時: {UpdatedTime}(サイト更新日時)/設定バージョン `Ver: {Ver}`
|
||||
|
||||
## 1. サイト基本情報
|
||||
(TenantId, Title, ReferenceType, ParentId, InheritPermission, Publish,
|
||||
DisableCrossSearch, Creator/Updator, CreatedTime, SiteSettings.Version を表形式で)
|
||||
|
||||
## 2. アクセス権限(Permissions)
|
||||
(Permissions配列を 種別/対象ID/権限レベル の表に分解。
|
||||
「権限値はビットフラグの加算方式のため正確な意味は環境のロール定義に依存する」旨を注記)
|
||||
|
||||
## 3. 画面構成
|
||||
### 3.1 一覧画面(GridColumns)
|
||||
(GridColumns配列を 順番/列名/表示ラベル の表に。ラベルはColumns配列から解決、
|
||||
無ければ「項目種別の判定ルール」のデフォルト名を使う)
|
||||
|
||||
### 3.2 編集画面レイアウト(EditorColumnHash / Sections)
|
||||
(EditorColumnHashの各タブ配列を 順番/項目名(内部名)/ラベル名 の表に。
|
||||
`_Section-N` はラベル名欄に「―(セクション「{LabelText}」開始)」と記載。
|
||||
続けてSections配列を Id/ラベル/AllowExpand/Expand の表で示す)
|
||||
|
||||
### 3.3 タイトル表示(TitleColumns / TitleSeparator)
|
||||
(TitleColumns配列をTitleSeparatorで連結した式を明記)
|
||||
|
||||
## 4. 項目定義(Columns)
|
||||
(Columns配列を 列名(内部)/種別/表示ラベル/説明・入力ガイド/備考 の表に。
|
||||
種別は下記「項目種別の判定ルール」で推定。
|
||||
備考にはNoWrap, FieldCss, ExtendedHtmlAfterControlの有無などを記載)
|
||||
|
||||
### 4.1 項目ごとの補足HTML(ExtendedHtmlAfterControl)
|
||||
(該当項目があれば 列名/色(styleやclassから読み取れれば)/内容 の表。無ければこの節は省略)
|
||||
|
||||
## 5. 作成・更新権限(フィールド単位)
|
||||
(PermissionForCreating / PermissionForUpdating を表で。無ければ「設定なし」と明記)
|
||||
|
||||
## 6. 集計設定(Aggregations)
|
||||
(存在する場合のみ。Id/GroupBy/Type/Target を表で)
|
||||
|
||||
## 7. 他サイト連携(Links / ルックアップ)
|
||||
(存在する場合のみ。起点列/参照先SiteId/参照先の値→自コピー先/JsonFormat を表で。
|
||||
「参照先サイトの{From}列の値を、このサイトの{To}列にコピーする」という関係性を文章でも補足)
|
||||
|
||||
## 8. プロセス設定(Processes)
|
||||
(存在する場合のみ。定義ファイルへのリンク configs/site-{SiteId}_{サイト名}/processes.json を明記した上で、
|
||||
Id/Name・DisplayName/実行条件(CurrentStatus→ChangedStatus)/動作(OnClick等)/成功メッセージ を表で)
|
||||
|
||||
## 9. スタイル(Styles)
|
||||
(存在する場合のみ。Id/Title/適用範囲(New/Edit/Index等のフラグ)/ファイルへのリンクを表で。
|
||||
CSS本体はMarkdownに貼らず、抽出済みファイルへのリンクのみとする)
|
||||
|
||||
## 10. スクリプト(Scripts)/サーバースクリプト(ServerScripts)
|
||||
(存在する場合のみ。Id/Title/適用画面/ファイルへのリンクを表で。
|
||||
スクリプト本体はMarkdownに貼らず、抽出済みファイルへのリンクのみとする。
|
||||
概要は「何をするスクリプトか」を1〜2行で要約。
|
||||
本文中にAPIキー・Webhook URL・パスワード等の秘密情報らしき文字列(`API_KEY`, `SECRET`, `Bearer `,
|
||||
URLに埋め込まれたトークン等)を見つけた場合は、値そのものをMarkdownに転記せず、
|
||||
「気になる点」に必ずセキュリティ上の指摘として記載する)
|
||||
|
||||
## 11. その他設定
|
||||
(NoDisplayIfReadOnly, HideLink, SectionLatestId, 各種ガイド文の設定有無, Commentsの使用有無など)
|
||||
|
||||
---
|
||||
|
||||
## 気になる点(レビュー観点)
|
||||
(プレースホルダーらしき文言、権限設定の偏り、項目単位の権限の抜け、
|
||||
未設定のガイド文など、気づいた点を箇条書きで)
|
||||
```
|
||||
|
||||
## 項目種別の判定ルール(Columns内 ColumnName のプレフィックスから推定)
|
||||
|
||||
| プレフィックス/名前 | 種別 |
|
||||
|---|---|
|
||||
| ClassA〜Z | 文字列(分類) |
|
||||
| Class001〜040等(3桁連番) | 文字列(拡張分類)。`Hide: true`の場合が多く、他項目のルックアップ先の値を裏で保持する用途が多い |
|
||||
| NumA〜Z | 数値 |
|
||||
| DateA〜Z | 日付 |
|
||||
| DescriptionA〜Z | 説明(複数行文字列) |
|
||||
| CheckA〜Z | チェックボックス |
|
||||
| AttachmentsA〜Z | 添付ファイル |
|
||||
| Owner, Manager, Assignee等 | ユーザー選択 |
|
||||
| Status | 状態 |
|
||||
| {対象}Id(ResultId, IssueId等) | 自動採番ID |
|
||||
| Title | タイトル |
|
||||
| Comments | コメント欄 |
|
||||
|
||||
## 注意事項
|
||||
|
||||
- SiteSettingsに存在しない項目・空の設定(例: `ClassHash`, `NumHash` などのラベル定義ハッシュが `{}`)は
|
||||
「標準ラベルのまま」として仕様書に明記し、存在しないかのように省略しない。
|
||||
- 表形式を基本とし、箇条書きより表を優先する(今回のレビューで「編集画面レイアウトは項目名とラベル名を併記」との
|
||||
指摘を受けた経緯があるため、内部名とラベル名は必ず併記する)。
|
||||
- 出力言語は日本語。
|
||||
- 複数サイトが関連する仕様書(「関連するテーブルと合わせて」等の依頼)を作る場合は、対象サイトの`Links`設定だけでなく、
|
||||
ServerScripts内で`items.Get(masterTableId)`のように直接SiteIdをハードコードして参照している「正式なLinks設定に現れない
|
||||
暗黙的なテーブル参照」も洗い出し、別セクションとして明記する。あわせて`ParentId`を辿ってサイトツリー上の位置関係も
|
||||
簡潔に図示するとレビューしやすい。
|
||||
|
||||
## 概要書(業務担当者向け機能説明)の生成
|
||||
|
||||
技術仕様書(spec.md)とは別に、「概要書を作って」「機能的な説明がほしい」等の依頼があった場合は、
|
||||
`ColumnName`(ClassA, DateB, Class001等の内部コード)やJSON設定値・スクリプト実装といった技術的要素を一切排除し、
|
||||
ラベル名と業務フローのみで構成した概要書を作成する。
|
||||
|
||||
### 出力ファイル
|
||||
|
||||
`./docs/site-{SiteId}_overview.md`(`configs/`ではなく`docs/`フォルダに置く。`configs/`は秘密情報を含み得るため
|
||||
`.gitignore`済みだが、`docs/`配下の概要書・PDFは共有物としてGit管理対象にする想定)。
|
||||
`docs/`フォルダが無ければ作成する。
|
||||
|
||||
### 概要書の構成
|
||||
|
||||
```markdown
|
||||
# 概要書:{Title}
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [{spec.mdへのリンク}](../configs/site-{SiteId}_spec.md) を参照。
|
||||
本書は業務担当者向けに、機能面のみを平易に説明するものです。
|
||||
|
||||
## 1. これは何か
|
||||
(業務的にこのサイトが何のためのものかを2〜4文で)
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
(分類/項目(ラベル名のみ)/内容 の表。ColumnNameは記載しない。Hide項目でも
|
||||
業務上意味のあるもの(自動反映される裏項目等)は「内容」欄で触れる)
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
(操作/状態の変化(ラベル値同士、例:「新規作成→図確回覧」)/説明 の表。
|
||||
Processesの内部処理・スクリプト名には触れず、「何が起きるか」を業務言葉で説明する)
|
||||
|
||||
## 4. 通知される場面
|
||||
(Notificationsを「◯◯時に誰に何が届くか」という箇条書きに変換。メール本文・件名の技術的書式は転記しない)
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
(Links・暗黙的テーブル参照を「◯◯マスタ:何を自動反映するか」という業務目線の箇条書きに変換。
|
||||
SiteIdやColumnNameは書かず、サイト名(Title)とその役割のみ記載。
|
||||
兄弟サイト(同じ設計パターンの別サイト)があれば、違いを1文で触れる)
|
||||
```
|
||||
|
||||
### 作成時の注意
|
||||
|
||||
- 表内の「項目」列は必ずラベル名(LabelText)のみ。ラベル未設定の項目は概要書には載せない(業務上意味を説明できないため)。
|
||||
- Statusの遷移は内部コード(100/300/900等)を書かず、ラベル値(新規作成/完了 等)同士の矢印で表現する。
|
||||
- スクリプト内で見つけた秘密情報(APIキー等)は概要書には一切転記しない(そもそも概要書はスクリプト実装に触れない)。
|
||||
- 複数サイト分をまとめて依頼された場合、共通する業務フローは重複して書いてよいが、各サイト固有の違い(段階数の違い、
|
||||
追加の管理項目など)を各概要書の末尾で明示し、他サイトとの差分が一目で分かるようにする。
|
||||
|
||||
## PDF化
|
||||
|
||||
概要書(または他のMarkdown)をPDF化する依頼があった場合は、プロジェクトルートの `md-to-pdf.js` を使う。
|
||||
|
||||
```
|
||||
node md-to-pdf.js ./docs/site-{SiteId}_overview.md
|
||||
```
|
||||
|
||||
- 追加npmパッケージ非依存(本プロジェクトに`package.json`が無いことに合わせた設計)。Markdown→HTML変換は
|
||||
スクリプト内の簡易パーサ(見出し/表/太字/リンク/箇条書き/水平線/段落)で行い、PDF化はOS標準のMicrosoft Edge
|
||||
(無ければGoogle Chrome)のヘッドレス印刷機能を使う。
|
||||
- 出力は入力と同じフォルダに`{同名}.pdf`(中間HTMLは一時フォルダに作成し、変換後に自動削除)。
|
||||
- 複数ファイルを一度に渡せる(`node md-to-pdf.js ./docs/*.md`)。
|
||||
- **既知の落とし穴**: フォルダパスに`#`を含む場合(例: `#GitHub`)、素朴に`file://` + パス文字列を連結すると
|
||||
`#`以降がURLフラグメントと誤認識され、無関係なページが開かれてPDFの中身が空/別物になる。
|
||||
`md-to-pdf.js`は`url.pathToFileURL()`で正しくエンコードしているため通常は問題にならないが、
|
||||
同種のブラウザ自動化コードを新たに書く場合は同じ罠に注意する。
|
||||
|
||||
## 関連ツール:Scripts/Styles/ServerScripts/Processesの抽出・編集・反映
|
||||
|
||||
仕様書化とは別に、「スクリプトを個別ファイルに分けて」「プロセス設定を直して反映して」等の依頼では、
|
||||
以下のツールチェーンを使う(詳細は `README.md` 参照)。
|
||||
|
||||
1. `node get-site-config.js` … `configs/site-{SiteId}_latest.json` を取得し、続けて自動的に抽出も行う
|
||||
(`extract-site-config.js` が内部から呼ばれ、`configs/site-{SiteId}_{サイト名}/` に以下を分割保存する。未取得なら先にこれを実行)
|
||||
- `scripts/{Id}_{Title}.js`(クライアントスクリプト本体)
|
||||
- `styles/{Id}_{Title}.css`(スタイル本体)
|
||||
- `serverscripts/{Id}_{Title}.js`(サーバースクリプト本体)
|
||||
- `html/{GuideName}.html`(GridGuide等のガイドHTML。SiteSettingsではなくサイトデータ直下の項目)
|
||||
- `processes.json`(`SiteSettings.Processes` の定義ファイル、1ファイルにまとめる。分割しない)
|
||||
- `manifest.json`(上記ファイルとId/Titleの対応表。再構成に必須)
|
||||
- 抽出だけをやり直したい場合(`latest.json`は既に取得済み)は `node extract-site-config.js` を単独実行してもよい
|
||||
2. ユーザーの指示に従い、`scripts/*.js` 等の中身を直接編集する(`manifest.json` のId/メタ情報は変更しない)。
|
||||
新規追加の場合は `manifest.json` に対応エントリ(`Id`は既存と重複しない値、`File`名)を追加してからファイルを作成する。
|
||||
3. `node build-desired-config.js site-{SiteId}` … 分割ファイルを再構成し `desired_config.json` を生成(送信なし)。
|
||||
フォルダ名はサイト名まで含む正式名(`site-{SiteId}_{サイト名}`)だが、`site-{SiteId}` の前方一致指定でも
|
||||
`configs/`配下から自動解決される
|
||||
4. `node apply-site-config.js` … ドライラン確認(差分・送信Body・curlコマンド表示のみ、送信なし)
|
||||
5. 内容をユーザーに確認してもらい、問題なければ表示されたcurlコマンドをユーザー自身が手動実行する
|
||||
|
||||
### 厳守事項(安全設計)
|
||||
|
||||
- **`apply-site-config.js` は常にドライラン。Claude自身がAPIへ実送信することはしない**
|
||||
(`ENABLE_ACTUAL_SEND` を有効化する改修や、curlコマンドの代理実行も行わない)。
|
||||
反映の最終実行はユーザー自身に委ねる。
|
||||
- `build-desired-config.js` が作る `desired_config.json` は `Mode:"partial"`(`updatesitesettings`)専用。
|
||||
Scripts/Styles/ServerScripts/Processes以外のサイト設定には触れない。
|
||||
- ガイドHTML(`html/*.html`)は `updatesitesettings` では反映できない(サイト直下の項目のため)。
|
||||
反映が必要なら `Mode:"full"`(`updatesite`)用の別JSONが要ることをユーザーに伝える。自動生成はしない。
|
||||
- 抽出したスクリプト・サーバースクリプトの中に **APIキーやWebhook URL等の秘密情報がハードコードされている場合がある**
|
||||
(実例あり)。抽出・編集作業でこれらの値を見つけたら、チャット上にそのまま貼り付けず、ユーザーに直接ファイルを確認してもらう。
|
||||
また `configs/` フォルダと `config.json` は `.gitignore` 済みだが、誤ってコミットしないよう注意喚起する。
|
||||
9
ClaudePleasanter/助っ人クラウド/.gitignore
vendored
Normal file
9
ClaudePleasanter/助っ人クラウド/.gitignore
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
# Pleasanter API接続情報(実APIキーを含む)
|
||||
config.json
|
||||
|
||||
# サイト構成の取得結果・抽出結果(サイトのスクリプト等に秘密情報が
|
||||
# ハードコードされている場合があるため、フォルダごと除外)
|
||||
configs/
|
||||
|
||||
# 反映用に生成される希望構成(機密を含むスクリプト等を含み得る)
|
||||
desired_config.json
|
||||
3
ClaudePleasanter/助っ人クラウド/.vscode/settings.json
vendored
Normal file
3
ClaudePleasanter/助っ人クラウド/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
{
|
||||
"markdown-pdf.executablePath": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe"
|
||||
}
|
||||
101
ClaudePleasanter/助っ人クラウド/README.md
Normal file
101
ClaudePleasanter/助っ人クラウド/README.md
Normal file
@ -0,0 +1,101 @@
|
||||
# プリザンター サイト構成 取得・レビュー・反映ツール
|
||||
|
||||
## 全体の流れ
|
||||
|
||||
```
|
||||
① node get-site-config.js
|
||||
→ configs/site-{SiteId}_latest.json に構成を保存
|
||||
→ 続けて自動的に extract-site-config.js が実行され、
|
||||
Scripts / Styles / ServerScripts / Processes / ガイドHTML が
|
||||
configs/site-{SiteId}_{サイト名}/ 配下に個別ファイルとして分割保存される
|
||||
→ config.json の SiteId をカンマ区切りにすると、複数サイトをまとめて①の処理でループ取得する
|
||||
|
||||
② その latest.json をClaudeのチャットにアップロード
|
||||
→ Claudeが構成をレビュー・仕様書化(configs/site-{SiteId}_spec.md)
|
||||
|
||||
③ configs/site-{SiteId}_{サイト名}/ 配下のファイルを直接編集、
|
||||
またはClaudeに「このスクリプトを◯◯に直して」等と修正を指示
|
||||
|
||||
④ node build-desired-config.js site-{SiteId}
|
||||
→ 分割ファイルを再構成し、desired_config.json を生成(送信は行わない)
|
||||
|
||||
⑤ node apply-site-config.js
|
||||
→ 実際には送信せず、差分・送信予定Body・curlコマンドのみ表示(常にドライラン)
|
||||
|
||||
⑥ 内容を確認し、問題なければ表示されたcurlコマンドを手動実行
|
||||
|
||||
--- (任意:非エンジニア向けドキュメントが必要な場合) ---
|
||||
|
||||
⑦ Claudeに「概要書を作って」と依頼
|
||||
→ 技術仕様書とは別に、ColumnName等の内部コードを含まない業務向け概要書を
|
||||
docs/site-{SiteId}_overview.md として生成(configs/とは異なりGit管理対象)
|
||||
|
||||
⑧ node md-to-pdf.js ./docs/site-{SiteId}_overview.md
|
||||
→ 同名の docs/site-{SiteId}_overview.pdf を生成
|
||||
```
|
||||
|
||||
`extract-site-config.js` は `node get-site-config.js` の中から自動実行されるため、通常は個別に実行する必要はありません
|
||||
(取得済みの `latest.json` から抽出をやり直したい場合のみ、単独で `node extract-site-config.js` を実行してください)。
|
||||
|
||||
## 初回セットアップ
|
||||
|
||||
1. `config.example.json` を `config.json` にコピー
|
||||
2. `BaseUrl` / `SiteId` / `ApiKey` を実環境の値に書き換え
|
||||
- `SiteId` は単一サイトなら数値(例: `12345`)、複数サイトをまとめて取得したい場合は
|
||||
カンマ区切りの文字列(例: `"12345,23456,34567"`)で指定できます
|
||||
- APIキーの発行方法: https://pleasanter.org/manual/api-key
|
||||
- APIキー発行にはテナント管理者権限が必要です(サイト設定の更新系APIを使う場合)
|
||||
3. Node.js 18以降がインストールされていることを確認(組み込みfetchを使用、追加パッケージ不要)
|
||||
|
||||
## ファイル構成
|
||||
|
||||
| ファイル | 役割 |
|
||||
|---|---|
|
||||
| `config.json` | 接続情報(BaseUrl/SiteId/ApiKey)※Git管理・共有厳禁 |
|
||||
| `config.example.json` | 上記のテンプレート |
|
||||
| `get-site-config.js` | サイト構成取得(`getsite`) → `configs/site-{SiteId}_latest.json` に保存し、続けて `extract-site-config.js` を自動実行。`SiteId` をカンマ区切りにすると全サイトを1回の実行で順次取得 |
|
||||
| `extract-site-config.js` | `latest.json` から Scripts/Styles/ServerScripts/Processes/ガイドHTML を `configs/site-{SiteId}_{サイト名}/` に個別ファイル分割(`get-site-config.js` から自動呼び出し、または単独実行も可) |
|
||||
| `build-desired-config.js` | 分割ファイルを再構成し `desired_config.json` を生成(**送信は行わない**) |
|
||||
| `apply-site-config.js` | 希望構成JSONとの差分表示・送信内容確認(**送信は行わない**) |
|
||||
| `desired_config.json` | Claude、または `build-desired-config.js` が生成する「希望構成」 |
|
||||
| `configs/site-{SiteId}_latest.json` | 直近取得した現状構成(Claudeへのアップロード対象) |
|
||||
| `configs/site-{SiteId}_spec.md` | SiteSettingsを読み解いたMarkdown仕様書 |
|
||||
| `configs/site-{SiteId}_{サイト名}/manifest.json` | 分割ファイルの一覧(Id/Title/ファイル名の対応表) |
|
||||
| `configs/site-{SiteId}_{サイト名}/scripts/*.js` | クライアントスクリプト本体(1ファイル=1スクリプト) |
|
||||
| `configs/site-{SiteId}_{サイト名}/styles/*.css` | スタイル本体 |
|
||||
| `configs/site-{SiteId}_{サイト名}/serverscripts/*.js` | サーバースクリプト本体 |
|
||||
| `configs/site-{SiteId}_{サイト名}/html/*.html` | ガイドHTML(GridGuide等。値が空でないもののみ) |
|
||||
| `configs/site-{SiteId}_{サイト名}/processes.json` | プロセス設定(`SiteSettings.Processes` の定義ファイル) |
|
||||
| `md-to-pdf.js` | Markdownファイルを同名の`.pdf`に変換(追加npmパッケージ非依存、OS標準のEdge/Chromeのヘッドレス印刷を利用) |
|
||||
| `docs/site-{SiteId}_overview.md` | 業務担当者向けの概要書(ColumnName等の内部コードを含まない機能説明)。`configs/`と異なりGit管理対象 |
|
||||
| `docs/site-{SiteId}_overview.pdf` | 上記概要書のPDF版 |
|
||||
|
||||
## Scripts/Styles/ServerScripts/Processes の反映について
|
||||
|
||||
- `build-desired-config.js` が生成する `desired_config.json` は `Mode:"partial"`(`updatesitesettings`)です。
|
||||
Scripts / Styles / ServerScripts / Processes の4項目のみを対象とし、それ以外のサイト設定(GridColumns等)には触れません。
|
||||
- ガイドHTML(`html/*.html`)はSiteSettingsではなく**サイトデータ直下**の項目のため、`updatesitesettings` では反映できません。
|
||||
反映したい場合は `Mode:"full"`(`updatesite`)用の別の `desired_config.json` を用意し、`Title`/`ReferenceType` 等の必須項目とあわせて指定してください。
|
||||
- 抽出(`extract-site-config.js`)はその時点のスナップショットです。抽出後に元サイトが他の人・画面から変更されている可能性がある場合は、
|
||||
反映前に `node get-site-config.js` → `node extract-site-config.js` を再実行し、最新状態を取り直してから編集してください。
|
||||
|
||||
## 参照した公式API仕様
|
||||
|
||||
- サイト情報取得: `POST {BaseUrl}/api/items/{SiteId}/getsite`
|
||||
https://pleasanter.org/ja/manual/api-site-get
|
||||
(※ `/api/items/{SiteId}/get` はアイテム一覧取得APIであり別物)
|
||||
- サイト全体更新: `POST {BaseUrl}/api/items/{SiteId}/updatesite`
|
||||
https://pleasanter.org/manual/api-site-update
|
||||
- サイト設定部分更新: `POST {BaseUrl}/api/items/{SiteId}/updatesitesettings`
|
||||
https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
|
||||
## 安全設計について
|
||||
|
||||
- `apply-site-config.js` は **常にドライラン** です。デフォルトでは実際のAPI呼び出しコード自体に到達しないようガード(`ENABLE_ACTUAL_SEND = false` 固定)しています。
|
||||
- 反映する際は、表示されたcurlコマンドをご自身で実行するか、内容を確認の上でスクリプトを改修してください(Claudeが安全策なしに自動実行することはありません)。
|
||||
- APIキーは `config.json` にのみ保持し、Claudeにアップロードする `latest.json` には含まれません(get時のレスポンスにAPIキーは含まれないため)。念のためアップロード前に目視確認することをおすすめします。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- プリザンターのバージョンにより `get` レスポンスの構造(`Response.Site.SiteSettings` の位置など)が異なる場合があります。初回実行時にコンソールへ出力されるキー構造を確認してください。
|
||||
- サイト設定の更新系APIは **テナント管理者権限のAPIキー** が必要です(一般ユーザーのAPIキーでは403等になります)。
|
||||
156
ClaudePleasanter/助っ人クラウド/apply-site-config.js
Normal file
156
ClaudePleasanter/助っ人クラウド/apply-site-config.js
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* apply-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* ★★★ このスクリプトは常にドライランです。実際のAPI送信は一切行いません。★★★
|
||||
*
|
||||
* Claudeが生成した desired_config.json(希望構成)を読み込み、
|
||||
* 1. 現状構成(configs/site-{SiteId}_latest.json)との差分表示
|
||||
* 2. 実際に送信されるはずの HTTPリクエスト(URL / Body)の表示
|
||||
* 3. そのまま使えるcurlコマンドの出力
|
||||
* のみを行います。実行(fetch送信)は行いません。
|
||||
*
|
||||
* 内容を確認した上で、問題なければ、
|
||||
* - 表示されたcurlコマンドを手動で実行する
|
||||
* - または、このファイル末尾の "実送信ブロック" のコメントを
|
||||
* 自分の判断で外して実行する
|
||||
* のいずれかで反映してください。
|
||||
*
|
||||
* desired_config.json の形式:
|
||||
* {
|
||||
* "Mode": "partial", // "partial" = updatesitesettings(部分更新) / "full" = updatesite(全体更新)
|
||||
* "SiteId": 12345, // 省略時は config.json の値を使用
|
||||
* "SiteSettings": { ... }, // 変更したい項目のみ(partialの場合)/ 全体(fullの場合)
|
||||
* "Title": "...", // full更新時のみ必要な場合あり
|
||||
* "ReferenceType": "...",
|
||||
* "ParentId": ...,
|
||||
* "InheritPermission": ...
|
||||
* }
|
||||
*
|
||||
* 参照した公式マニュアル:
|
||||
* - サイト更新(全体): https://pleasanter.org/manual/api-site-update
|
||||
* URL: POST {BaseUrl}/api/items/{SiteId}/updatesite
|
||||
* - サイト設定の部分更新: https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
* URL: POST {BaseUrl}/api/items/{SiteId}/updatesitesettings
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadJson(p, required = true) {
|
||||
if (!fs.existsSync(p)) {
|
||||
if (required) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
// ざっくりした差分表示(キー単位・トップ1階層+SiteSettings1階層まで)
|
||||
// partialモード(updatesitesettings)は desired 側にあるキーだけがサーバーに送られ、
|
||||
// 記載のないキーは現状のまま変わらない。そのため比較対象は afterKeys のみに限定する
|
||||
// (beforeにしかないキーを「削除される」ように見せるのは誤り)。
|
||||
// fullモード(updatesite)はSiteSettings全体を丸ごと置き換えるため、beforeKeysも含めて比較する。
|
||||
function diffTopLevel(before, after, label, mode = "partial") {
|
||||
if (!before) {
|
||||
console.log(` (現状データなし。get-site-config.jsで先に取得しておくと差分表示できます)`);
|
||||
return;
|
||||
}
|
||||
const afterKeys = new Set(Object.keys(after || {}));
|
||||
const allKeys = mode === "full" ? new Set([...Object.keys(before || {}), ...afterKeys]) : afterKeys;
|
||||
|
||||
let changed = false;
|
||||
for (const key of allKeys) {
|
||||
const b = JSON.stringify(before?.[key]);
|
||||
const a = JSON.stringify(after?.[key]);
|
||||
if (b !== a) {
|
||||
changed = true;
|
||||
console.log(` [変更] ${label}.${key}`);
|
||||
console.log(` - 現状: ${truncate(b)}`);
|
||||
console.log(` + 希望: ${truncate(a)}`);
|
||||
}
|
||||
}
|
||||
if (!changed) console.log(" 差分なし");
|
||||
}
|
||||
|
||||
function truncate(str, max = 200) {
|
||||
if (str === undefined) return "(undefined)";
|
||||
return str.length > max ? str.slice(0, max) + "...(略)" : str;
|
||||
}
|
||||
|
||||
function maskApiKey(key) {
|
||||
if (!key) return key;
|
||||
return key.length > 8 ? key.slice(0, 4) + "****" + key.slice(-4) : "****";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = __dirname;
|
||||
const configPath = path.join(baseDir, "config.json");
|
||||
const desiredPath = process.argv[2] || path.join(baseDir, "desired_config.json");
|
||||
|
||||
const config = loadJson(configPath);
|
||||
const desired = loadJson(desiredPath);
|
||||
|
||||
const siteId = desired.SiteId || config.SiteId;
|
||||
const mode = desired.Mode || "partial";
|
||||
const endpoint = mode === "full" ? "updatesite" : "updatesitesettings";
|
||||
const url = `${config.BaseUrl.replace(/\/+$/, "")}/api/items/${siteId}/${endpoint}`;
|
||||
|
||||
const latestConfigPath = path.join(baseDir, "configs", `site-${siteId}_latest.json`);
|
||||
const current = loadJson(latestConfigPath, false);
|
||||
const currentSiteSettings =
|
||||
current?.Response?.Data?.SiteSettings ||
|
||||
current?.Response?.Site?.SiteSettings ||
|
||||
current?.Response?.SiteSettings ||
|
||||
null;
|
||||
|
||||
console.log("========================================");
|
||||
console.log(" ドライラン結果(実際の送信は行いません)");
|
||||
console.log("========================================");
|
||||
console.log(`Mode : ${mode} (${mode === "full" ? "全体更新" : "部分更新"})`);
|
||||
console.log(`URL : ${url}`);
|
||||
console.log(`SiteId : ${siteId}`);
|
||||
console.log("");
|
||||
console.log("--- SiteSettings 差分(現状 → 希望) ---");
|
||||
diffTopLevel(currentSiteSettings, desired.SiteSettings, "SiteSettings", mode);
|
||||
console.log("");
|
||||
|
||||
const body = {
|
||||
ApiVersion: config.ApiVersion || "1.1",
|
||||
ApiKey: config.ApiKey,
|
||||
...desired,
|
||||
};
|
||||
delete body.Mode; // Mode はエンドポイント選択にのみ使用し、送信Bodyには含めない
|
||||
|
||||
const maskedBody = { ...body, ApiKey: maskApiKey(body.ApiKey) };
|
||||
console.log("--- 送信されるはずのBody(APIキーはマスク表示) ---");
|
||||
console.log(JSON.stringify(maskedBody, null, 2));
|
||||
console.log("");
|
||||
|
||||
console.log("--- 手動実行用 curl コマンド(内容確認の上、必要ならご自身で実行してください) ---");
|
||||
console.log(
|
||||
`curl -X POST "${url}" -H "Content-Type: application/json" -d '${JSON.stringify(body)}'`
|
||||
);
|
||||
console.log("");
|
||||
console.log("[注意] このスクリプトはAPIへの送信を一切行っていません(ドライラン専用)。");
|
||||
console.log("内容に問題がなければ、上記curlコマンドを手動実行するか、");
|
||||
console.log("スクリプト内の「実送信ブロック」を有効化する改修をご自身の判断で行ってください。");
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 実送信ブロック(デフォルトでは絶対に到達しないようにガードしています)
|
||||
// 意図的に反映を自動化したい場合のみ、下のガードを外して使ってください。
|
||||
// ------------------------------------------------------------
|
||||
const ENABLE_ACTUAL_SEND = false; // ← 安全のため常にfalse固定。変更は自己責任で。
|
||||
if (ENABLE_ACTUAL_SEND) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
console.log(await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
139
ClaudePleasanter/助っ人クラウド/build-desired-config.js
Normal file
139
ClaudePleasanter/助っ人クラウド/build-desired-config.js
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* build-desired-config.js
|
||||
* ------------------------------------------------------------
|
||||
* extract-site-config.js で分割した configs/site-{SiteId}/ 配下のファイル
|
||||
* (scripts/*.js, styles/*.css, serverscripts/*.js, processes.json)を
|
||||
* manifest.json を手がかりに再構成し、apply-site-config.js が読み込む
|
||||
* desired_config.json を生成します。
|
||||
*
|
||||
* ★★★ このスクリプトもAPIへは一切送信しません(ファイル生成のみ)。★★★
|
||||
* 反映は既存の運用どおり、
|
||||
* 1. node apply-site-config.js でドライラン確認(差分・送信Body・curl表示)
|
||||
* 2. 内容を確認し、問題なければ表示されたcurlコマンドを手動実行
|
||||
* の流れに乗せてください。
|
||||
*
|
||||
* 使い方:
|
||||
* node build-desired-config.js site-214812
|
||||
* (フォルダ名は "site-{SiteId}_{サイト名}" 形式だが、"site-{SiteId}" だけの
|
||||
* 前方一致指定でも configs/ 配下から自動的に該当フォルダを探して解決する)
|
||||
* node build-desired-config.js ./configs/site-214812_物件基本情報
|
||||
*
|
||||
* 出力: desired_config.json(このスクリプトと同じ階層。既存ファイルは上書き)
|
||||
*
|
||||
* 対応範囲:
|
||||
* Scripts / Styles / ServerScripts / Processes
|
||||
* → Mode:"partial"(updatesitesettings)で反映可能なため、そのまま組み込みます。
|
||||
* Html(GridGuide等のガイド)
|
||||
* → SiteSettingsではなくサイトデータ直下の項目のため、
|
||||
* 部分更新(updatesitesettings)には含められません。
|
||||
* 反映したい場合は Mode:"full"(updatesite)用の別JSONを用意し、
|
||||
* Title/ReferenceType等の必須項目とあわせて指定してください。
|
||||
* このスクリプトはhtml/配下に抽出物がある場合、警告のみ表示します。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function readJson(p) {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
// フォルダ名は "site-{SiteId}_{サイト名}" 形式。"site-{SiteId}" のみの指定でも
|
||||
// configs/ 配下から前方一致で該当フォルダを探して解決する。
|
||||
// 新形式(サイト名付き)を優先し、裸の "site-{SiteId}" 完全一致(リネーム前の古い抽出結果の可能性)は
|
||||
// 他に候補が無い場合のみ後方互換フォールバックとして使う。
|
||||
function resolveSiteDir(baseDir, arg) {
|
||||
const configsDir = path.join(baseDir, "configs");
|
||||
const direct = path.isAbsolute(arg) ? arg : path.join(configsDir, path.basename(arg));
|
||||
const baseName = path.basename(arg);
|
||||
|
||||
const candidates = fs.existsSync(configsDir)
|
||||
? fs.readdirSync(configsDir).filter((name) => {
|
||||
const full = path.join(configsDir, name);
|
||||
return (
|
||||
fs.statSync(full).isDirectory() && (name === baseName || name.startsWith(`${baseName}_`))
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const suffixed = candidates.filter((name) => name !== baseName);
|
||||
if (suffixed.length === 1) return path.join(configsDir, suffixed[0]);
|
||||
if (suffixed.length > 1) {
|
||||
console.error(`[エラー] "${baseName}" に一致するフォルダが複数見つかりました: ${suffixed.join(", ")}`);
|
||||
console.error("フォルダ名をフルで指定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (candidates.includes(baseName)) return path.join(configsDir, baseName);
|
||||
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) return direct;
|
||||
|
||||
console.error(`[エラー] フォルダが見つかりません: ${direct}`);
|
||||
console.error("先に node extract-site-config.js(またはnode get-site-config.js)を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rebuildList(manifestEntries, dir, bodyKey = "Body") {
|
||||
return (manifestEntries || []).map((entry) => {
|
||||
const { File, ...meta } = entry;
|
||||
const filePath = path.join(dir, File);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`[エラー] manifest.jsonに記載のファイルが見つかりません: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const body = fs.readFileSync(filePath, "utf-8");
|
||||
return { ...meta, [bodyKey]: body };
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const baseDir = __dirname;
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error("[エラー] 対象フォルダを指定してください(例: node build-desired-config.js site-214812)");
|
||||
process.exit(1);
|
||||
}
|
||||
const siteDir = resolveSiteDir(baseDir, arg);
|
||||
|
||||
const manifestPath = path.join(siteDir, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`[エラー] manifest.jsonが見つかりません: ${manifestPath}`);
|
||||
console.error("先に node extract-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = readJson(manifestPath);
|
||||
|
||||
const siteSettings = {};
|
||||
|
||||
siteSettings.Scripts = rebuildList(manifest.Scripts, path.join(siteDir, "scripts"));
|
||||
siteSettings.Styles = rebuildList(manifest.Styles, path.join(siteDir, "styles"));
|
||||
siteSettings.ServerScripts = rebuildList(manifest.ServerScripts, path.join(siteDir, "serverscripts"));
|
||||
|
||||
const processesPath = path.join(siteDir, manifest.ProcessesFile || "processes.json");
|
||||
siteSettings.Processes = fs.existsSync(processesPath) ? readJson(processesPath) : [];
|
||||
|
||||
const desired = {
|
||||
Mode: "partial",
|
||||
SiteId: manifest.SiteId,
|
||||
SiteSettings: siteSettings,
|
||||
};
|
||||
|
||||
const outPath = path.join(baseDir, "desired_config.json");
|
||||
fs.writeFileSync(outPath, JSON.stringify(desired, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 生成しました: ${outPath}`);
|
||||
console.log(` Scripts : ${siteSettings.Scripts.length}件`);
|
||||
console.log(` Styles : ${siteSettings.Styles.length}件`);
|
||||
console.log(` ServerScripts : ${siteSettings.ServerScripts.length}件`);
|
||||
console.log(` Processes : ${siteSettings.Processes.length}件`);
|
||||
|
||||
if (manifest.Html && manifest.Html.length > 0) {
|
||||
console.log("\n[注意] html/ 配下のガイドHTMLは、この desired_config.json には含まれていません。");
|
||||
console.log(" GridGuide等はSiteSettingsではなくサイト直下の項目のため、部分更新(updatesitesettings)では送れません。");
|
||||
console.log(' 反映するには Mode:"full"(updatesite)用の別JSONを用意し、Title等の必須項目とあわせて指定してください。');
|
||||
}
|
||||
|
||||
console.log("\n次のステップ: node apply-site-config.js でドライラン確認 → 内容確認の上、表示されたcurlコマンドを手動実行してください。");
|
||||
}
|
||||
|
||||
main();
|
||||
6
ClaudePleasanter/助っ人クラウド/config.example.json
Normal file
6
ClaudePleasanter/助っ人クラウド/config.example.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"BaseUrl": "http://your-pleasanter-server/",
|
||||
"SiteId": "12345,23456",
|
||||
"ApiKey": "ここに発行したAPIキーを貼り付け",
|
||||
"ApiVersion": "1.1"
|
||||
}
|
||||
195
ClaudePleasanter/助っ人クラウド/extract-site-config.js
Normal file
195
ClaudePleasanter/助っ人クラウド/extract-site-config.js
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* extract-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* 取得済みのサイト構成 (configs/site-{SiteId}_latest.json) から、
|
||||
* Scripts / Styles / ServerScripts / Processes / ガイドHTML を
|
||||
* 個別ファイルとして configs/site-{SiteId}/ 配下に書き出します。
|
||||
*
|
||||
* 使い方:
|
||||
* node extract-site-config.js
|
||||
* node extract-site-config.js ./configs/site-214812_latest.json
|
||||
*
|
||||
* 出力:
|
||||
* configs/site-{SiteId}/
|
||||
* manifest.json ← 抽出内容の一覧(build-desired-config.jsでの再構成に使用)
|
||||
* scripts/{Id}_{Title}.js
|
||||
* styles/{Id}_{Title}.css
|
||||
* serverscripts/{Id}_{Title}.js
|
||||
* html/{GuideName}.html ← GridGuide等、ガイドHTML(値が空でないもののみ)
|
||||
* processes.json ← プロセス設定(SiteSettings.Processes をそのまま保存)
|
||||
*
|
||||
* 注意:
|
||||
* ガイドHTML(GridGuide等)はSiteSettingsではなく、サイトデータ直下の項目です。
|
||||
* そのため反映時はサイト設定の部分更新(updatesitesettings)ではなく、
|
||||
* サイト全体更新(updatesite / Mode:"full")が必要になります。
|
||||
* Scripts/Styles/ServerScripts/Processesは部分更新(Mode:"partial")で反映できます。
|
||||
* 詳しくは build-desired-config.js の実行結果メッセージを参照してください。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function sanitize(name) {
|
||||
return (
|
||||
String(name || "untitled")
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 60) || "untitled"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function extractList(items, outDir, ext, bodyKey = "Body") {
|
||||
const manifestEntries = [];
|
||||
if (!items || items.length === 0) return manifestEntries;
|
||||
ensureDir(outDir);
|
||||
for (const item of items) {
|
||||
const fileName = `${item.Id}_${sanitize(item.Title || item.Name)}${ext}`;
|
||||
fs.writeFileSync(path.join(outDir, fileName), item[bodyKey] || "", "utf-8");
|
||||
const { [bodyKey]: _omit, ...meta } = item;
|
||||
manifestEntries.push({ ...meta, File: fileName });
|
||||
}
|
||||
return manifestEntries;
|
||||
}
|
||||
|
||||
// config.json の SiteId(数値 / カンマ区切り文字列 / 配列)を
|
||||
// 個々のSiteId文字列の配列に正規化する(get-site-config.jsと同仕様)。
|
||||
function parseSiteIds(rawSiteId) {
|
||||
const list = Array.isArray(rawSiteId) ? rawSiteId : String(rawSiteId).split(",");
|
||||
return list
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => id.length > 0);
|
||||
}
|
||||
|
||||
const GUIDE_FIELDS = [
|
||||
"GridGuide",
|
||||
"EditorGuide",
|
||||
"CalendarGuide",
|
||||
"CrosstabGuide",
|
||||
"GanttGuide",
|
||||
"BurnDownGuide",
|
||||
"TimeSeriesGuide",
|
||||
"AnalyGuide",
|
||||
"KambanGuide",
|
||||
"ImageLibGuide",
|
||||
];
|
||||
|
||||
// get-site-config.js からも呼び出せるよう関数化。
|
||||
// CLI実行時(直接 `node extract-site-config.js`)は末尾のブロックが引数解決を行う。
|
||||
function extractSiteConfig(latestPath) {
|
||||
const baseDir = __dirname;
|
||||
|
||||
if (!fs.existsSync(latestPath)) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${latestPath}`);
|
||||
console.error("先に node get-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(latestPath, "utf-8"));
|
||||
const data = json?.Response?.Data;
|
||||
if (!data) {
|
||||
console.error("[エラー] Response.Data が見つかりません。getsiteのレスポンス形式を確認してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteId = data.SiteId;
|
||||
const settings = data.SiteSettings || {};
|
||||
const dirName = `site-${siteId}_${sanitize(data.Title)}`;
|
||||
const outRoot = path.join(baseDir, "configs", dirName);
|
||||
ensureDir(outRoot);
|
||||
|
||||
const manifest = {
|
||||
SiteId: siteId,
|
||||
Title: data.Title,
|
||||
ExtractedAt: new Date().toISOString(),
|
||||
SourceFile: path.relative(baseDir, latestPath).replace(/\\/g, "/"),
|
||||
};
|
||||
|
||||
manifest.Scripts = extractList(settings.Scripts, path.join(outRoot, "scripts"), ".js");
|
||||
manifest.Styles = extractList(settings.Styles, path.join(outRoot, "styles"), ".css");
|
||||
manifest.ServerScripts = extractList(settings.ServerScripts, path.join(outRoot, "serverscripts"), ".js");
|
||||
|
||||
// ガイドHTML(サイトデータ直下。SiteSettingsではない点に注意)
|
||||
const htmlDir = path.join(outRoot, "html");
|
||||
const htmlEntries = [];
|
||||
for (const field of GUIDE_FIELDS) {
|
||||
const value = data[field];
|
||||
if (value) {
|
||||
ensureDir(htmlDir);
|
||||
const fileName = `${field}.html`;
|
||||
fs.writeFileSync(path.join(htmlDir, fileName), value, "utf-8");
|
||||
htmlEntries.push({ Field: field, File: fileName, TopLevel: true });
|
||||
}
|
||||
}
|
||||
manifest.Html = htmlEntries;
|
||||
|
||||
// プロセス設定 … 個別分割せず1つの定義ファイルとして保存
|
||||
fs.writeFileSync(
|
||||
path.join(outRoot, "processes.json"),
|
||||
JSON.stringify(settings.Processes || [], null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
manifest.ProcessesFile = "processes.json";
|
||||
manifest.ProcessesCount = (settings.Processes || []).length;
|
||||
|
||||
fs.writeFileSync(path.join(outRoot, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 抽出完了: ${outRoot}`);
|
||||
console.log(` Scripts : ${manifest.Scripts.length}件`);
|
||||
console.log(` Styles : ${manifest.Styles.length}件`);
|
||||
console.log(` ServerScripts : ${manifest.ServerScripts.length}件`);
|
||||
console.log(` Html(ガイド) : ${manifest.Html.length}件`);
|
||||
console.log(` Processes : ${manifest.ProcessesCount}件 → processes.json`);
|
||||
console.log("\n次のステップ: 各ファイルを直接編集するか、Claudeに修正内容を指示してください。");
|
||||
console.log(`修正後は次のコマンドで反映用JSONを作成できます: node build-desired-config.js ${dirName}`);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// CLIとして直接実行された場合のみ、引数からlatestPathを解決して実行する
|
||||
if (require.main === module) {
|
||||
const baseDir = __dirname;
|
||||
const latestPathArg = process.argv[2];
|
||||
|
||||
if (latestPathArg) {
|
||||
extractSiteConfig(latestPathArg);
|
||||
} else {
|
||||
const configPath = path.join(baseDir, "config.json");
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error("[エラー] config.jsonが見つかりません。抽出対象のJSONパスを引数で指定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const siteIds = parseSiteIds(config.SiteId);
|
||||
if (siteIds.length === 0) {
|
||||
console.error("[エラー] config.json の SiteId から有効なサイトIDを取得できませんでした。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
for (const siteId of siteIds) {
|
||||
const latestPath = path.join(baseDir, "configs", `site-${siteId}_latest.json`);
|
||||
if (!fs.existsSync(latestPath)) {
|
||||
console.error(`[警告] ファイルが見つかりません(スキップ): ${latestPath}`);
|
||||
missing.push(siteId);
|
||||
continue;
|
||||
}
|
||||
if (siteIds.length > 1) {
|
||||
console.log(`\n==== SiteId: ${siteId} ====`);
|
||||
}
|
||||
extractSiteConfig(latestPath);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`\n[エラー] 未取得のSiteIdがあります: ${missing.join(", ")}`);
|
||||
console.error("先に node get-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extractSiteConfig };
|
||||
166
ClaudePleasanter/助っ人クラウド/get-site-config.js
Normal file
166
ClaudePleasanter/助っ人クラウド/get-site-config.js
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* get-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* プリザンターの指定サイトの構成情報(SiteSettings等)を取得し、
|
||||
* ./configs/ 配下にタイムスタンプ付きJSONとして保存します。
|
||||
*
|
||||
* 使い方:
|
||||
* node get-site-config.js
|
||||
* node get-site-config.js ./config.json ← 設定ファイルを明示指定する場合
|
||||
*
|
||||
* 事前準備:
|
||||
* 1. config.example.json を config.json にコピーし、
|
||||
* BaseUrl / SiteId / ApiKey を実環境の値に書き換えてください。
|
||||
* SiteId はカンマ区切りで複数指定可能です(例: "480111,438166,96235")。
|
||||
* 1件のみの場合は従来通り数値(例: 480111)でも構いません。
|
||||
* 2. Node.js 18以降(組み込みfetchを使用)
|
||||
*
|
||||
* 参照した公式マニュアル:
|
||||
* - 開発者向け機能:API:サイト操作:サイト更新
|
||||
* https://pleasanter.org/manual/api-site-update
|
||||
* - 開発者向け機能:API:サイト操作:サイト設定の更新(部分追加/更新/削除)
|
||||
* https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
*
|
||||
* 注意:
|
||||
* プリザンターのバージョンにより get のレスポンス構造
|
||||
* (SiteSettingsが Response.Site 配下にあるか等)が異なる場合があります。
|
||||
* 初回実行時はコンソールに出力されるレスポンスのトップレベルキーを確認し、
|
||||
* 想定通りの構造か確認してください。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { extractSiteConfig } = require("./extract-site-config");
|
||||
|
||||
// config.json の SiteId(数値 / カンマ区切り文字列 / 配列)を
|
||||
// 個々のSiteId文字列の配列に正規化する。
|
||||
function parseSiteIds(rawSiteId) {
|
||||
const list = Array.isArray(rawSiteId) ? rawSiteId : String(rawSiteId).split(",");
|
||||
return list
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => id.length > 0);
|
||||
}
|
||||
|
||||
// 1サイト分の取得・保存・抽出処理
|
||||
async function fetchAndSaveSite(siteId, { BaseUrl, ApiKey, ApiVersion }) {
|
||||
const url = `${BaseUrl.replace(/\/+$/, "")}/api/items/${siteId}/getsite`;
|
||||
const body = {
|
||||
ApiVersion: ApiVersion || "1.1",
|
||||
ApiKey: ApiKey,
|
||||
};
|
||||
|
||||
console.log(`[INFO] 取得先: ${url}`);
|
||||
console.log(`[INFO] SiteId: ${siteId}`);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[エラー] リクエスト送信に失敗しました:", err.message);
|
||||
console.error("BaseUrlの疎通・ネットワーク設定を確認してください。");
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[エラー] HTTP ${response.status} ${response.statusText}`);
|
||||
console.error(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch (err) {
|
||||
console.error("[エラー] レスポンスがJSONとして解析できませんでした。");
|
||||
console.error(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
// レスポンス構造の確認用ログ(初回はここを見て構造を把握してください)
|
||||
console.log("[INFO] レスポンス トップレベルキー:", Object.keys(json));
|
||||
if (json.Response) {
|
||||
console.log("[INFO] Response配下のキー:", Object.keys(json.Response));
|
||||
}
|
||||
|
||||
// 保存
|
||||
const outDir = path.join(__dirname, "configs");
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-");
|
||||
const outFile = path.join(outDir, `site-${siteId}_${timestamp}.json`);
|
||||
|
||||
fs.writeFileSync(outFile, JSON.stringify(json, null, 2), "utf-8");
|
||||
|
||||
// 常に「最新」ファイルも別名で保持(Claudeへのアップロード用に固定名があると便利)
|
||||
const latestFile = path.join(outDir, `site-${siteId}_latest.json`);
|
||||
fs.writeFileSync(latestFile, JSON.stringify(json, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 保存しました: ${outFile}`);
|
||||
console.log(`[OK] 最新版としても保存しました: ${latestFile}`);
|
||||
|
||||
// 取得と同時に Scripts/Styles/ServerScripts/Processes/ガイドHTML も抽出する
|
||||
console.log("\n[INFO] 続けて設定の個別ファイル抽出を実行します...");
|
||||
extractSiteConfig(latestFile);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = process.argv[2] || path.join(__dirname, "config.json");
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error(`[エラー] 設定ファイルが見つかりません: ${configPath}`);
|
||||
console.error("config.example.json をコピーして config.json を作成してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const { BaseUrl, SiteId, ApiKey, ApiVersion } = config;
|
||||
|
||||
if (!BaseUrl || !SiteId || !ApiKey) {
|
||||
console.error("[エラー] config.json に BaseUrl / SiteId / ApiKey を設定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteIds = parseSiteIds(SiteId);
|
||||
if (siteIds.length === 0) {
|
||||
console.error("[エラー] config.json の SiteId から有効なサイトIDを取得できませんでした。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const multiple = siteIds.length > 1;
|
||||
const results = [];
|
||||
|
||||
for (const [index, siteId] of siteIds.entries()) {
|
||||
if (multiple) {
|
||||
console.log(`\n==== [${index + 1}/${siteIds.length}] SiteId: ${siteId} ====`);
|
||||
}
|
||||
const ok = await fetchAndSaveSite(siteId, { BaseUrl, ApiKey, ApiVersion });
|
||||
results.push({ siteId, ok });
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
console.log("\n==== 取得結果まとめ ====");
|
||||
for (const { siteId, ok } of results) {
|
||||
console.log(` SiteId ${siteId}: ${ok ? "OK" : "失敗"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n次のステップ: latest.jsonをClaudeのチャットにアップロードして仕様書化を依頼するか、");
|
||||
console.log("configs/site-{SiteId}/ 配下のファイルを直接編集・指示して修正してください。");
|
||||
|
||||
if (results.some((r) => !r.ok)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
233
ClaudePleasanter/助っ人クラウド/md-to-pdf.js
Normal file
233
ClaudePleasanter/助っ人クラウド/md-to-pdf.js
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* md-to-pdf.js
|
||||
* ------------------------------------------------------------
|
||||
* Markdownファイル(見出し/表/太字/リンク/箇条書き/水平線程度の書式)を
|
||||
* 同名の .pdf として書き出します。外部npmパッケージ非依存(プロジェクトに
|
||||
* package.jsonが無いことに合わせ、Node標準モジュールのみで完結させています)。
|
||||
* PDF化にはOS標準のMicrosoft Edge(無ければGoogle Chrome)のヘッドレス印刷機能を使用します。
|
||||
*
|
||||
* 使い方:
|
||||
* node md-to-pdf.js ./docs/site-475384_overview.md
|
||||
* node md-to-pdf.js ./docs/*.md (シェルのグロブ展開に依存。複数ファイル可)
|
||||
*
|
||||
* 出力:
|
||||
* 入力と同じフォルダに {同名}.pdf を生成(中間HTMLは生成後に削除)
|
||||
*
|
||||
* 注意:
|
||||
* フォルダ名に # を含むパス(例: #GitHub)は file:// URL上でフラグメント区切りと
|
||||
* 誤認識されるため、url.pathToFileURL() で必ずエンコードしてからブラウザに渡すこと。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const url = require("url");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const CANDIDATE_BROWSERS = [
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
];
|
||||
|
||||
function findBrowser() {
|
||||
for (const p of CANDIDATE_BROWSERS) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
console.error("[エラー] Microsoft EdgeまたはGoogle Chromeが見つかりませんでした。");
|
||||
console.error("インストール済みのブラウザパスをCANDIDATE_BROWSERSに追加してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- 最小限のMarkdown→HTML変換(見出し/表/太字/リンク/箇条書き/水平線/段落のみ対応) ---
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
let t = escapeHtml(text);
|
||||
t = t.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
return t;
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && line.includes("-");
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
let cells = line.trim();
|
||||
if (cells.startsWith("|")) cells = cells.slice(1);
|
||||
if (cells.endsWith("|")) cells = cells.slice(0, -1);
|
||||
return cells.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function markdownToHtml(mdText) {
|
||||
const lines = mdText.split(/\r?\n/);
|
||||
const html = [];
|
||||
let i = 0;
|
||||
let inList = false;
|
||||
|
||||
function closeList() {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (/^\s*$/.test(line)) {
|
||||
closeList();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*---+\s*$/.test(line)) {
|
||||
closeList();
|
||||
html.push("<hr>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// テーブル: 次行がセパレータ行であること
|
||||
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||
closeList();
|
||||
const headerCells = splitTableRow(line);
|
||||
html.push("<table><thead><tr>" + headerCells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr></thead><tbody>");
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].includes("|") && !/^\s*$/.test(lines[i])) {
|
||||
const rowCells = splitTableRow(lines[i]);
|
||||
html.push("<tr>" + rowCells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
|
||||
i++;
|
||||
}
|
||||
html.push("</tbody></table>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = line.match(/^\s*-\s+(.*)$/);
|
||||
if (listItem) {
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${renderInline(listItem[1])}</li>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
closeList();
|
||||
html.push(`<p>${renderInline(line)}</p>`);
|
||||
i++;
|
||||
}
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
body {
|
||||
font-family: "Yu Gothic", "Meiryo", "Hiragino Sans", sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.7;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
h1 { font-size: 18pt; border-bottom: 3px solid #2a5599; padding-bottom: 6px; margin-top: 0; }
|
||||
h2 { font-size: 14pt; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-top: 28px; color: #2a5599; }
|
||||
h3 { font-size: 12pt; margin-top: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0 16px; font-size: 9.5pt; }
|
||||
th, td { border: 1px solid #999; padding: 5px 8px; text-align: left; vertical-align: top; }
|
||||
th { background-color: #2a5599; color: #fff; }
|
||||
tr:nth-child(even) td { background-color: #f4f7fb; }
|
||||
ul, ol { margin: 6px 0; padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
p { margin: 8px 0; }
|
||||
code { background: #eee; padding: 1px 4px; border-radius: 3px; }
|
||||
hr { border: none; border-top: 1px solid #ccc; margin: 20px 0; }
|
||||
a { color: #2a5599; }
|
||||
`;
|
||||
|
||||
function convertOne(mdPath, browser) {
|
||||
const srcPath = path.resolve(mdPath);
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${srcPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mdText = fs.readFileSync(srcPath, "utf-8");
|
||||
const titleMatch = mdText.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : path.basename(srcPath);
|
||||
const bodyHtml = markdownToHtml(mdText);
|
||||
|
||||
const htmlDoc = `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const tmpHtmlPath = path.join(os.tmpdir(), `md-to-pdf-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
|
||||
const pdfPath = srcPath.replace(/\.md$/i, ".pdf");
|
||||
|
||||
fs.writeFileSync(tmpHtmlPath, htmlDoc, "utf-8");
|
||||
|
||||
try {
|
||||
const fileUrl = url.pathToFileURL(tmpHtmlPath).href;
|
||||
execFileSync(browser, [
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
`--print-to-pdf=${pdfPath}`,
|
||||
"--print-to-pdf-no-header",
|
||||
"--no-pdf-header-footer",
|
||||
fileUrl,
|
||||
]);
|
||||
console.log(`[OK] PDF生成: ${pdfPath}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[エラー] PDF生成に失敗しました: ${srcPath}`);
|
||||
console.error(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
fs.unlinkSync(tmpHtmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const targets = process.argv.slice(2);
|
||||
if (targets.length === 0) {
|
||||
console.error("[エラー] 変換対象のMarkdownファイルを引数で指定してください。");
|
||||
console.error("例: node md-to-pdf.js ./docs/site-475384_overview.md");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = findBrowser();
|
||||
let ok = true;
|
||||
for (const target of targets) {
|
||||
ok = convertOne(target, browser) && ok;
|
||||
}
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = { convertOne, markdownToHtml };
|
||||
5579
ClaudePleasanter/助っ人クラウド/助っ人クラウド_2026_07_13 18_41_58.json
Normal file
5579
ClaudePleasanter/助っ人クラウド/助っ人クラウド_2026_07_13 18_41_58.json
Normal file
File diff suppressed because it is too large
Load Diff
8
ClaudePleasanter/営業積算システム/.claude/settings.json
Normal file
8
ClaudePleasanter/営業積算システム/.claude/settings.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -c get-site-config.js)",
|
||||
"Edit(/.claude/skills/pleasanter-site-spec/**)"
|
||||
]
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,266 @@
|
||||
---
|
||||
name: pleasanter-site-spec
|
||||
description: Pleasanterのサイト設定JSON(get-site-config.js / api-site-get の getsite レスポンス)から、SiteSettingsを読み解いたMarkdown仕様書を生成し、Scripts/Styles/ServerScripts/Processesの抽出・編集・反映(extract-site-config.js / build-desired-config.js / apply-site-config.js)を行う。また、技術仕様書とは別に業務担当者向けの概要書(docs/site-{SiteId}_{サイト名}_overview.md)を作成し、md-to-pdf.jsでPDF化する。「仕様書を作って」「サイト仕様書」「SiteSettingsをドキュメント化」「スクリプトを抜き出して」「プロセス設定を直して反映して」「概要書を作って」「PDFにして」「site-spec」等の依頼で使う。
|
||||
---
|
||||
|
||||
# Pleasanter サイト仕様書生成
|
||||
|
||||
`./siteSettingJsons/site-{SiteId}_latest.json`(`getsite` APIレスポンス、`Response.Data` 配下にサイト情報)と、
|
||||
`./configs/site-{SiteId}_{サイト名}/`(`extract-site-config.js` による抽出済みScripts/Styles/ServerScripts/Processes)を
|
||||
読み解き、`./docs/site-{SiteId}_{サイト名}_spec.md` として日本語のMarkdown仕様書を出力する。
|
||||
|
||||
この仕様書はClaudeが対話的に読み解いて執筆するものであり、機械的なテンプレート処理で自動生成するものではない
|
||||
(`generate-site-documentation.js` は過去に試みられた機械生成版で、サイト間の暗黙的な関連や業務的な類推まではできず
|
||||
品質面の問題があったため現在のワークフローでは使用しない。削除はされていないが参照しないこと)。
|
||||
|
||||
## 入力の特定
|
||||
|
||||
1. ユーザーがJSONファイルパスやSiteIdを指定した場合はそれを使う。
|
||||
2. 指定がなければ `./siteSettingJsons/site-*_latest.json` を探す。複数ある場合はユーザーに確認する。
|
||||
3. `./siteSettingJsons/site-{SiteId}_latest.json` が無ければ、先に `node get-site-config.js` の実行を提案する
|
||||
(エンドポイントは `/api/items/{SiteId}/getsite` を使うこと。`/api/items/{SiteId}/get` はアイテム一覧取得APIであり別物なので注意)。
|
||||
|
||||
## 出力ファイル
|
||||
|
||||
`./docs/site-{SiteId}_{サイト名}_spec.md`(サイト名は `configs/site-{SiteId}_{サイト名}/` のフォルダ名と揃える。既存があれば上書きしてよい)。
|
||||
`docs/`フォルダが無ければ作成する。
|
||||
|
||||
## 仕様書の構成(この順番・見出しレベルを踏襲する)
|
||||
|
||||
```markdown
|
||||
# サイト仕様書:{Title}(SiteId: {SiteId})
|
||||
|
||||
- 取得元データ: [site-{SiteId}_latest.json](../siteSettingJsons/site-{SiteId}_latest.json)
|
||||
- 取得日時: {UpdatedTime}(サイト更新日時)/設定バージョン `Ver: {Ver}`
|
||||
|
||||
## 1. サイト基本情報
|
||||
(TenantId, Title, ReferenceType, ParentId, InheritPermission, Publish,
|
||||
DisableCrossSearch, Creator/Updator, CreatedTime, SiteSettings.Version を表形式で)
|
||||
|
||||
## 2. アクセス権限(Permissions)
|
||||
(Permissions配列を 種別/対象ID/権限レベル の表に分解。
|
||||
「権限値はビットフラグの加算方式のため正確な意味は環境のロール定義に依存する」旨を注記)
|
||||
|
||||
## 3. 画面構成
|
||||
### 3.1 一覧画面(GridColumns)
|
||||
(GridColumns配列を 順番/列名/表示ラベル の表に。ラベルはColumns配列から解決、
|
||||
無ければ「項目種別の判定ルール」のデフォルト名を使う)
|
||||
|
||||
### 3.2 編集画面レイアウト(EditorColumnHash / Sections)
|
||||
(EditorColumnHashの各タブ配列を 順番/項目名(内部名)/ラベル名 の表に。
|
||||
`_Section-N` はラベル名欄に「―(セクション「{LabelText}」開始)」と記載。
|
||||
続けてSections配列を Id/ラベル/AllowExpand/Expand の表で示す)
|
||||
|
||||
### 3.3 タイトル表示(TitleColumns / TitleSeparator)
|
||||
(TitleColumns配列をTitleSeparatorで連結した式を明記)
|
||||
|
||||
## 4. 項目定義(Columns)
|
||||
(Columns配列を 列名(内部)/種別/表示ラベル/説明・入力ガイド/備考 の表に。
|
||||
種別は下記「項目種別の判定ルール」で推定。
|
||||
備考にはNoWrap, FieldCss, ExtendedHtmlAfterControlの有無などを記載)
|
||||
|
||||
### 4.1 項目ごとの補足HTML(ExtendedHtmlAfterControl)
|
||||
(該当項目があれば 列名/色(styleやclassから読み取れれば)/内容 の表。無ければこの節は省略)
|
||||
|
||||
## 5. 作成・更新権限(フィールド単位)
|
||||
(PermissionForCreating / PermissionForUpdating を表で。無ければ「設定なし」と明記)
|
||||
|
||||
## 6. 集計設定(Aggregations)
|
||||
(存在する場合のみ。Id/GroupBy/Type/Target を表で)
|
||||
|
||||
## 7. 他サイト連携(Links / ルックアップ)
|
||||
(存在する場合のみ。起点列/参照先SiteId/参照先の値→自コピー先/JsonFormat を表で。
|
||||
「参照先サイトの{From}列の値を、このサイトの{To}列にコピーする」という関係性を文章でも補足)
|
||||
|
||||
## 8. プロセス設定(Processes)
|
||||
(存在する場合のみ。定義ファイルへのリンク configs/site-{SiteId}_{サイト名}/processes.json を明記した上で、
|
||||
Id/Name・DisplayName/実行条件(CurrentStatus→ChangedStatus)/動作(OnClick等)/成功メッセージ を表で)
|
||||
|
||||
## 9. スタイル(Styles)
|
||||
(存在する場合のみ。Id/Title/適用範囲(New/Edit/Index等のフラグ)/ファイルへのリンクを表で。
|
||||
CSS本体はMarkdownに貼らず、抽出済みファイルへのリンクのみとする)
|
||||
|
||||
## 10. スクリプト(Scripts)/サーバースクリプト(ServerScripts)
|
||||
(存在する場合のみ。Id/Title/適用画面/ファイルへのリンクを表で。
|
||||
スクリプト本体はMarkdownに貼らず、抽出済みファイルへのリンクのみとする。
|
||||
概要は「何をするスクリプトか」を1〜2行で要約。
|
||||
本文中にAPIキー・Webhook URL・パスワード等の秘密情報らしき文字列(`API_KEY`, `SECRET`, `Bearer `,
|
||||
URLに埋め込まれたトークン等)を見つけた場合は、値そのものをMarkdownに転記せず、
|
||||
「気になる点」に必ずセキュリティ上の指摘として記載する)
|
||||
|
||||
## 11. その他設定
|
||||
(NoDisplayIfReadOnly, HideLink, SectionLatestId, 各種ガイド文の設定有無, Commentsの使用有無など)
|
||||
|
||||
---
|
||||
|
||||
## 気になる点(レビュー観点)
|
||||
(プレースホルダーらしき文言、権限設定の偏り、項目単位の権限の抜け、
|
||||
未設定のガイド文など、気づいた点を箇条書きで)
|
||||
```
|
||||
|
||||
## 項目種別の判定ルール(Columns内 ColumnName のプレフィックスから推定)
|
||||
|
||||
| プレフィックス/名前 | 種別 |
|
||||
|---|---|
|
||||
| ClassA〜Z | 文字列(分類) |
|
||||
| Class001〜040等(3桁連番) | 文字列(拡張分類)。`Hide: true`の場合が多く、他項目のルックアップ先の値を裏で保持する用途が多い |
|
||||
| NumA〜Z | 数値 |
|
||||
| DateA〜Z | 日付 |
|
||||
| DescriptionA〜Z | 説明(複数行文字列) |
|
||||
| CheckA〜Z | チェックボックス |
|
||||
| AttachmentsA〜Z | 添付ファイル |
|
||||
| Owner, Manager, Assignee等 | ユーザー選択 |
|
||||
| Status | 状態 |
|
||||
| {対象}Id(ResultId, IssueId等) | 自動採番ID |
|
||||
| Title | タイトル |
|
||||
| Comments | コメント欄 |
|
||||
|
||||
## 注意事項
|
||||
|
||||
- SiteSettingsに存在しない項目・空の設定(例: `ClassHash`, `NumHash` などのラベル定義ハッシュが `{}`)は
|
||||
「標準ラベルのまま」として仕様書に明記し、存在しないかのように省略しない。
|
||||
- 表形式を基本とし、箇条書きより表を優先する(今回のレビューで「編集画面レイアウトは項目名とラベル名を併記」との
|
||||
指摘を受けた経緯があるため、内部名とラベル名は必ず併記する)。
|
||||
- 出力言語は日本語。
|
||||
- 複数サイトが関連する仕様書(「関連するテーブルと合わせて」等の依頼)を作る場合は、対象サイトの`Links`設定だけでなく、
|
||||
ServerScripts内で`items.Get(masterTableId)`のように直接SiteIdをハードコードして参照している「正式なLinks設定に現れない
|
||||
暗黙的なテーブル参照」も洗い出し、別セクションとして明記する。あわせて`ParentId`を辿ってサイトツリー上の位置関係も
|
||||
簡潔に図示するとレビューしやすい。
|
||||
|
||||
## 概要書(業務担当者向け機能説明)の生成
|
||||
|
||||
技術仕様書(spec.md)とは別に、「概要書を作って」「機能的な説明がほしい」等の依頼があった場合は、
|
||||
`ColumnName`(ClassA, DateB, Class001等の内部コード)やJSON設定値・スクリプト実装といった技術的要素を一切排除し、
|
||||
ラベル名と業務フローのみで構成した概要書を作成する。
|
||||
|
||||
### 出力ファイル
|
||||
|
||||
`./docs/site-{SiteId}_{サイト名}_overview.md`(`siteSettingJsons/`・`configs/`ではなく`docs/`フォルダに置く。
|
||||
これらは秘密情報を含み得るため`.gitignore`済みだが、`docs/`配下のspec.md・概要書・PDFは共有物としてGit管理対象にする想定)。
|
||||
`docs/`フォルダが無ければ作成する。
|
||||
|
||||
### 概要書の構成
|
||||
|
||||
```markdown
|
||||
# 概要書:{Title}
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [{spec.mdへのリンク}](./site-{SiteId}_{サイト名}_spec.md) を参照。
|
||||
本書は業務担当者向けに、機能面のみを平易に説明するものです。
|
||||
|
||||
## 1. これは何か
|
||||
(業務的にこのサイトが何のためのものかを2〜4文で)
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
(分類/項目(ラベル名のみ)/内容 の表。ColumnNameは記載しない。Hide項目でも
|
||||
業務上意味のあるもの(自動反映される裏項目等)は「内容」欄で触れる)
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
(操作/状態の変化(ラベル値同士、例:「新規作成→図確回覧」)/説明 の表。
|
||||
Processesの内部処理・スクリプト名には触れず、「何が起きるか」を業務言葉で説明する)
|
||||
|
||||
## 4. 通知される場面
|
||||
(Notificationsを「◯◯時に誰に何が届くか」という箇条書きに変換。メール本文・件名の技術的書式は転記しない)
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
(Links・暗黙的テーブル参照を「◯◯マスタ:何を自動反映するか」という業務目線の箇条書きに変換。
|
||||
SiteIdやColumnNameは書かず、サイト名(Title)とその役割のみ記載。
|
||||
兄弟サイト(同じ設計パターンの別サイト)があれば、違いを1文で触れる)
|
||||
```
|
||||
|
||||
### 作成時の注意
|
||||
|
||||
- 表内の「項目」列は必ずラベル名(LabelText)のみ。ラベル未設定の項目は概要書には載せない(業務上意味を説明できないため)。
|
||||
- Statusの遷移は内部コード(100/300/900等)を書かず、ラベル値(新規作成/完了 等)同士の矢印で表現する。
|
||||
- スクリプト内で見つけた秘密情報(APIキー等)は概要書には一切転記しない(そもそも概要書はスクリプト実装に触れない)。
|
||||
- 複数サイト分をまとめて依頼された場合、共通する業務フローは重複して書いてよいが、各サイト固有の違い(段階数の違い、
|
||||
追加の管理項目など)を各概要書の末尾で明示し、他サイトとの差分が一目で分かるようにする。
|
||||
|
||||
## 複数サイトの全体関連図(site-relations_overview.md)
|
||||
|
||||
`config.json` の `SiteId` に複数サイトが列挙されている場合など、「全体の関連図を作って」「サイト間の連携をまとめて」
|
||||
等の依頼があれば、個々のサイト仕様書とは別に `./docs/site-relations_overview.md` を作成する。対象は
|
||||
`config.json` の `SiteId` に列挙された全サイト(または依頼で指定された範囲)。
|
||||
|
||||
### 出力ファイルと構成
|
||||
|
||||
`./docs/site-relations_overview.md`(同名`.pdf`も作成対象)。以下を1枚で俯瞰できるようにまとめる。
|
||||
|
||||
```markdown
|
||||
# サイト関連図:{システム名など}
|
||||
|
||||
## 1. サイトツリー(ParentId)
|
||||
(`ParentId` を辿った親子関係を、簡易的なツリー表記かMermaid `graph` で図示。
|
||||
各ノードにSiteIdとサイト名(Title)を併記)
|
||||
|
||||
## 2. サイト一覧
|
||||
(SiteId/サイト名/役割(1行)の表。概要書へのリンクも併記)
|
||||
|
||||
## 3. 明示的な連携(Links)
|
||||
(各サイトの`Links`設定を 起点サイト/起点列/参照先サイト/参照先の値→コピー先 の表にまとめる。
|
||||
Idではなくサイト名で表現し、業務的に何を自動反映しているかも一言添える)
|
||||
|
||||
## 4. 暗黙的な連携(ServerScriptsのハードコード参照)
|
||||
(ServerScripts内で`items.Get(SiteId)`等により直接SiteIdを指定して他サイトを参照している箇所を洗い出し、
|
||||
起点サイト/参照先サイト/該当スクリプトファイル/目的(推測) の表にまとめる。
|
||||
正式なLinks設定には現れないため、レビュー上重要な情報として明記する)
|
||||
```
|
||||
|
||||
### 作成時の注意
|
||||
|
||||
- 各サイトの `spec.md`(7. 他サイト連携)で洗い出した内容を集約すればよい。個別spec.mdより先に全サイト分の
|
||||
spec.mdを作成しておくと効率的。
|
||||
- サイト名はTitleを使い、SiteIdは補助情報として併記する(概要書と違い、こちらは技術仕様書寄りの位置づけのため
|
||||
SiteId自体は隠さなくてよい)。
|
||||
|
||||
## PDF化
|
||||
|
||||
概要書(または他のMarkdown)をPDF化する依頼があった場合は、プロジェクトルートの `md-to-pdf.js` を使う。
|
||||
|
||||
```
|
||||
node md-to-pdf.js ./docs/site-{SiteId}_{サイト名}_spec.md ./docs/site-{SiteId}_{サイト名}_overview.md
|
||||
```
|
||||
|
||||
- 追加npmパッケージ非依存(本プロジェクトに`package.json`が無いことに合わせた設計)。Markdown→HTML変換は
|
||||
スクリプト内の簡易パーサ(見出し/表/太字/リンク/箇条書き/水平線/段落)で行い、PDF化はOS標準のMicrosoft Edge
|
||||
(無ければGoogle Chrome)のヘッドレス印刷機能を使う。
|
||||
- 出力は入力と同じフォルダに`{同名}.pdf`(中間HTMLは一時フォルダに作成し、変換後に自動削除)。
|
||||
- 複数ファイルを一度に渡せる(`node md-to-pdf.js ./docs/*.md`)。
|
||||
- **既知の落とし穴**: フォルダパスに`#`を含む場合(例: `#GitHub`)、素朴に`file://` + パス文字列を連結すると
|
||||
`#`以降がURLフラグメントと誤認識され、無関係なページが開かれてPDFの中身が空/別物になる。
|
||||
`md-to-pdf.js`は`url.pathToFileURL()`で正しくエンコードしているため通常は問題にならないが、
|
||||
同種のブラウザ自動化コードを新たに書く場合は同じ罠に注意する。
|
||||
|
||||
## 関連ツール:Scripts/Styles/ServerScripts/Processesの抽出・編集・反映
|
||||
|
||||
仕様書化とは別に、「スクリプトを個別ファイルに分けて」「プロセス設定を直して反映して」等の依頼では、
|
||||
以下のツールチェーンを使う(詳細は `README.md` 参照)。
|
||||
|
||||
1. `node get-site-config.js` … `siteSettingJsons/site-{SiteId}_latest.json` を取得し、続けて自動的に抽出も行う
|
||||
(`extract-site-config.js` が内部から呼ばれ、`configs/site-{SiteId}_{サイト名}/` に以下を分割保存する。未取得なら先にこれを実行)
|
||||
- `scripts/{Id}_{Title}.js`(クライアントスクリプト本体)
|
||||
- `styles/{Id}_{Title}.css`(スタイル本体)
|
||||
- `serverscripts/{Id}_{Title}.js`(サーバースクリプト本体)
|
||||
- `html/{GuideName}.html`(GridGuide等のガイドHTML。SiteSettingsではなくサイトデータ直下の項目)
|
||||
- `processes.json`(`SiteSettings.Processes` の定義ファイル、1ファイルにまとめる。分割しない)
|
||||
- `manifest.json`(上記ファイルとId/Titleの対応表。再構成に必須)
|
||||
- 抽出だけをやり直したい場合(`latest.json`は既に取得済み)は `node extract-site-config.js` を単独実行してもよい
|
||||
2. ユーザーの指示に従い、`scripts/*.js` 等の中身を直接編集する(`manifest.json` のId/メタ情報は変更しない)。
|
||||
新規追加の場合は `manifest.json` に対応エントリ(`Id`は既存と重複しない値、`File`名)を追加してからファイルを作成する。
|
||||
3. `node build-desired-config.js site-{SiteId}` … 分割ファイルを再構成し `desired_config.json` を生成(送信なし)。
|
||||
フォルダ名はサイト名まで含む正式名(`site-{SiteId}_{サイト名}`)だが、`site-{SiteId}` の前方一致指定でも
|
||||
`configs/`配下から自動解決される
|
||||
4. `node apply-site-config.js` … ドライラン確認(差分・送信Body・curlコマンド表示のみ、送信なし)
|
||||
5. 内容をユーザーに確認してもらい、問題なければ表示されたcurlコマンドをユーザー自身が手動実行する
|
||||
|
||||
### 厳守事項(安全設計)
|
||||
|
||||
- **`apply-site-config.js` は常にドライラン。Claude自身がAPIへ実送信することはしない**
|
||||
(`ENABLE_ACTUAL_SEND` を有効化する改修や、curlコマンドの代理実行も行わない)。
|
||||
反映の最終実行はユーザー自身に委ねる。
|
||||
- `build-desired-config.js` が作る `desired_config.json` は `Mode:"partial"`(`updatesitesettings`)専用。
|
||||
Scripts/Styles/ServerScripts/Processes以外のサイト設定には触れない。
|
||||
- ガイドHTML(`html/*.html`)は `updatesitesettings` では反映できない(サイト直下の項目のため)。
|
||||
反映が必要なら `Mode:"full"`(`updatesite`)用の別JSONが要ることをユーザーに伝える。自動生成はしない。
|
||||
- 抽出したスクリプト・サーバースクリプトの中に **APIキーやWebhook URL等の秘密情報がハードコードされている場合がある**
|
||||
(実例あり)。抽出・編集作業でこれらの値を見つけたら、チャット上にそのまま貼り付けず、ユーザーに直接ファイルを確認してもらう。
|
||||
また `siteSettingJsons/` フォルダ・`configs/` フォルダ・`config.json` は `.gitignore` 済みだが、誤ってコミットしないよう注意喚起する。
|
||||
16
ClaudePleasanter/営業積算システム/.gitignore
vendored
Normal file
16
ClaudePleasanter/営業積算システム/.gitignore
vendored
Normal file
@ -0,0 +1,16 @@
|
||||
# Pleasanter API接続情報(実APIキーを含む)
|
||||
config.json
|
||||
|
||||
# プリザンターから取得した生のサイト構成JSON(Scripts/ServerScripts本文に
|
||||
# 秘密情報が含まれている場合があるため、フォルダごと除外)
|
||||
siteSettingJsons/
|
||||
|
||||
# サイト構成の抽出結果(サイトのスクリプト等に秘密情報が
|
||||
# ハードコードされている場合があるため、フォルダごと除外)
|
||||
configs/
|
||||
|
||||
# 旧フォルダの退避先(configs/ 相当の中身を含むため同様に除外)
|
||||
configs_old/
|
||||
|
||||
# 反映用に生成される希望構成(機密を含むスクリプト等を含み得る)
|
||||
desired_config.json
|
||||
9
ClaudePleasanter/営業積算システム/.vscode/settings.json
vendored
Normal file
9
ClaudePleasanter/営業積算システム/.vscode/settings.json
vendored
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"markdown-pdf.executablePath": "C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
"/^node -e \"const fs=require\\('fs'\\);const path=require\\('path'\\);for\\(const f of \\['siteSettingJsons/site-335410_latest\\.json','siteSettingJsons/site-335411_latest\\.json'\\]\\)\\{const j=JSON\\.parse\\(fs\\.readFileSync\\(f,'utf8'\\)\\);const data=j\\.Response\\?\\.Data\\|\\|\\{\\};console\\.log\\('FILE',path\\.basename\\(f\\)\\);console\\.log\\('keys',Object\\.keys\\(data\\)\\.sort\\(\\)\\.join\\(','\\)\\);const ss=data\\.SiteSettings\\|\\|\\{\\};console\\.log\\('SiteSettings keys',Object\\.keys\\(ss\\)\\.sort\\(\\)\\.join\\(','\\)\\);const txt=JSON\\.stringify\\(j\\);const matches=\\[\\.\\.\\.txt\\.matchAll\\(/\\\\b\\(\\[0-9\\]\\{5,\\}\\)\\\\b/g\\)\\]\\.map\\(m=>m\\[1\\]\\);const uniq=\\[\\.\\.\\.new Set\\(matches\\)\\]\\.filter\\(x=>x!=='335410'&&x!=='335411'\\);console\\.log\\('numeric ids',uniq\\.slice\\(0,50\\)\\);console\\.log\\('---'\\);\\}\"$/": {
|
||||
"approve": true,
|
||||
"matchCommandLine": true
|
||||
}
|
||||
}
|
||||
}
|
||||
115
ClaudePleasanter/営業積算システム/README.md
Normal file
115
ClaudePleasanter/営業積算システム/README.md
Normal file
@ -0,0 +1,115 @@
|
||||
# プリザンター サイト構成 取得・レビュー・反映ツール
|
||||
|
||||
## 全体の流れ
|
||||
|
||||
```
|
||||
① node get-site-config.js
|
||||
→ siteSettingJsons/site-{SiteId}_latest.json に構成(生JSON)を保存
|
||||
→ 続けて自動的に extract-site-config.js が実行され、
|
||||
Scripts / Styles / ServerScripts / Processes / ガイドHTML が
|
||||
configs/site-{SiteId}_{サイト名}/ 配下に個別ファイルとして分割保存される
|
||||
→ config.json の SiteId をカンマ区切りにすると、複数サイトをまとめて①の処理でループ取得する
|
||||
|
||||
② Claudeに「仕様書を作って」と依頼
|
||||
→ siteSettingJsons/site-{SiteId}_latest.json と configs/site-{SiteId}_{サイト名}/ を
|
||||
Claudeが読み解き、docs/site-{SiteId}_{サイト名}_spec.md として仕様書化
|
||||
(機械的なテンプレート処理ではなく、Claudeが構成内容をレビューしながら執筆する)
|
||||
|
||||
③ configs/site-{SiteId}_{サイト名}/ 配下のファイルを直接編集、
|
||||
またはClaudeに「このスクリプトを◯◯に直して」等と修正を指示
|
||||
|
||||
④ node build-desired-config.js site-{SiteId}
|
||||
→ 分割ファイルを再構成し、desired_config.json を生成(送信は行わない)
|
||||
|
||||
⑤ node apply-site-config.js
|
||||
→ 実際には送信せず、差分・送信予定Body・curlコマンドのみ表示(常にドライラン)
|
||||
|
||||
⑥ 内容を確認し、問題なければ表示されたcurlコマンドを手動実行
|
||||
|
||||
--- (任意:非エンジニア向けドキュメントが必要な場合) ---
|
||||
|
||||
⑦ Claudeに「概要書を作って」と依頼
|
||||
→ 技術仕様書とは別に、ColumnName等の内部コードを含まない業務向け概要書を
|
||||
docs/site-{SiteId}_{サイト名}_overview.md として生成(siteSettingJsons/・configs/とは異なりGit管理対象)
|
||||
|
||||
⑧ 複数サイトが連携する場合、Claudeに「全体の関連図を作って」と依頼
|
||||
→ docs/site-relations_overview.md として、サイトツリー・明示的連携(Links)・
|
||||
暗黙的連携(ServerScriptsのハードコード参照)を1枚にまとめたドキュメントを生成
|
||||
|
||||
⑨ node md-to-pdf.js ./docs/*.md
|
||||
→ docs/ 配下の全Markdown(spec.md / overview.md / site-relations_overview.md)を
|
||||
同名の .pdf に一括変換
|
||||
```
|
||||
|
||||
`extract-site-config.js` は `node get-site-config.js` の中から自動実行されるため、通常は個別に実行する必要はありません
|
||||
(取得済みの `latest.json` から抽出をやり直したい場合のみ、単独で `node extract-site-config.js` を実行してください)。
|
||||
|
||||
仕様書・概要書(②⑦⑧)はClaudeが対話的に読み解いて執筆するものです。過去に `generate-site-documentation.js` という
|
||||
機械的なテンプレート処理での一括自動生成を試みましたが、サイト間の暗黙的な関連や業務的な類推までは反映できず
|
||||
品質面の問題があったため、**現在のワークフローでは使用していません**(ファイル自体は後方互換のため削除せず残置)。
|
||||
|
||||
## 初回セットアップ
|
||||
|
||||
1. `config.example.json` を `config.json` にコピー
|
||||
2. `BaseUrl` / `SiteId` / `ApiKey` を実環境の値に書き換え
|
||||
- `SiteId` は単一サイトなら数値(例: `12345`)、複数サイトをまとめて取得したい場合は
|
||||
カンマ区切りの文字列(例: `"12345,23456,34567"`)で指定できます
|
||||
- APIキーの発行方法: https://pleasanter.org/manual/api-key
|
||||
- APIキー発行にはテナント管理者権限が必要です(サイト設定の更新系APIを使う場合)
|
||||
3. Node.js 18以降がインストールされていることを確認(組み込みfetchを使用、追加パッケージ不要)
|
||||
|
||||
## ファイル構成
|
||||
|
||||
| ファイル | 役割 |
|
||||
|---|---|
|
||||
| `config.json` | 接続情報(BaseUrl/SiteId/ApiKey)※Git管理・共有厳禁 |
|
||||
| `config.example.json` | 上記のテンプレート |
|
||||
| `get-site-config.js` | サイト構成取得(`getsite`) → `siteSettingJsons/site-{SiteId}_latest.json` に保存し、続けて `extract-site-config.js` を自動実行。`SiteId` をカンマ区切りにすると全サイトを1回の実行で順次取得 |
|
||||
| `extract-site-config.js` | `latest.json` から Scripts/Styles/ServerScripts/Processes/ガイドHTML を `configs/site-{SiteId}_{サイト名}/` に個別ファイル分割(`get-site-config.js` から自動呼び出し、または単独実行も可) |
|
||||
| `build-desired-config.js` | 分割ファイルを再構成し `desired_config.json` を生成(**送信は行わない**) |
|
||||
| `apply-site-config.js` | 希望構成JSONとの差分表示・送信内容確認(**送信は行わない**) |
|
||||
| `generate-site-documentation.js` | **未使用(レガシー)**。仕様書・概要書を機械的なテンプレート処理で一括自動生成する旧スクリプト。品質面の問題から現在のワークフローでは使用しない。後方互換のため削除はしていない |
|
||||
| `md-to-pdf.js` | Markdownファイルを同名の`.pdf`に変換(追加npmパッケージ非依存、OS標準のEdge/Chromeヘッドレス印刷を利用) |
|
||||
| `desired_config.json` | Claude、または `build-desired-config.js` が生成する「希望構成」 |
|
||||
| `siteSettingJsons/site-{SiteId}_latest.json` | 直近取得した現状構成の生JSON(Claudeへのアップロード対象。`.gitignore`対象) |
|
||||
| `configs/site-{SiteId}_{サイト名}/manifest.json` | 分割ファイルの一覧(Id/Title/ファイル名の対応表) |
|
||||
| `configs/site-{SiteId}_{サイト名}/scripts/*.js` | クライアントスクリプト本体(1ファイル=1スクリプト) |
|
||||
| `configs/site-{SiteId}_{サイト名}/styles/*.css` | スタイル本体 |
|
||||
| `configs/site-{SiteId}_{サイト名}/serverscripts/*.js` | サーバースクリプト本体 |
|
||||
| `configs/site-{SiteId}_{サイト名}/html/*.html` | ガイドHTML(GridGuide等。値が空でないもののみ) |
|
||||
| `configs/site-{SiteId}_{サイト名}/processes.json` | プロセス設定(`SiteSettings.Processes` の定義ファイル) |
|
||||
| `docs/site-{SiteId}_{サイト名}_spec.md` | SiteSettingsを読み解いたMarkdown技術仕様書(Claudeが執筆。Git管理対象) |
|
||||
| `docs/site-{SiteId}_{サイト名}_overview.md` | 業務担当者向けの概要書(ColumnName等の内部コードを含まない機能説明。Git管理対象) |
|
||||
| `docs/site-relations_overview.md` | 複数サイトの全体関連図(サイトツリー・明示的/暗黙的連携をまとめたもの。Git管理対象) |
|
||||
| `docs/*.pdf` | 上記各Markdownに対応するPDF版 |
|
||||
|
||||
## Scripts/Styles/ServerScripts/Processes の反映について
|
||||
|
||||
- `build-desired-config.js` が生成する `desired_config.json` は `Mode:"partial"`(`updatesitesettings`)です。
|
||||
Scripts / Styles / ServerScripts / Processes の4項目のみを対象とし、それ以外のサイト設定(GridColumns等)には触れません。
|
||||
- ガイドHTML(`html/*.html`)はSiteSettingsではなく**サイトデータ直下**の項目のため、`updatesitesettings` では反映できません。
|
||||
反映したい場合は `Mode:"full"`(`updatesite`)用の別の `desired_config.json` を用意し、`Title`/`ReferenceType` 等の必須項目とあわせて指定してください。
|
||||
- 抽出(`extract-site-config.js`)はその時点のスナップショットです。抽出後に元サイトが他の人・画面から変更されている可能性がある場合は、
|
||||
反映前に `node get-site-config.js` → `node extract-site-config.js` を再実行し、最新状態を取り直してから編集してください。
|
||||
|
||||
## 参照した公式API仕様
|
||||
|
||||
- サイト情報取得: `POST {BaseUrl}/api/items/{SiteId}/getsite`
|
||||
https://pleasanter.org/ja/manual/api-site-get
|
||||
(※ `/api/items/{SiteId}/get` はアイテム一覧取得APIであり別物)
|
||||
- サイト全体更新: `POST {BaseUrl}/api/items/{SiteId}/updatesite`
|
||||
https://pleasanter.org/manual/api-site-update
|
||||
- サイト設定部分更新: `POST {BaseUrl}/api/items/{SiteId}/updatesitesettings`
|
||||
https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
|
||||
## 安全設計について
|
||||
|
||||
- `apply-site-config.js` は **常にドライラン** です。デフォルトでは実際のAPI呼び出しコード自体に到達しないようガード(`ENABLE_ACTUAL_SEND = false` 固定)しています。
|
||||
- 反映する際は、表示されたcurlコマンドをご自身で実行するか、内容を確認の上でスクリプトを改修してください(Claudeが安全策なしに自動実行することはありません)。
|
||||
- APIキーは `config.json` にのみ保持し、Claudeにアップロードする `latest.json` には含まれません(get時のレスポンスにAPIキーは含まれないため)。念のためアップロード前に目視確認することをおすすめします。
|
||||
- `siteSettingJsons/` と `configs/` はいずれもScripts/ServerScripts本文に秘密情報がハードコードされている場合があるため `.gitignore` 対象です。`docs/` 配下の仕様書・概要書・PDFのみがGit管理対象です。
|
||||
|
||||
## 注意事項
|
||||
|
||||
- プリザンターのバージョンにより `get` レスポンスの構造(`Response.Site.SiteSettings` の位置など)が異なる場合があります。初回実行時にコンソールへ出力されるキー構造を確認してください。
|
||||
- サイト設定の更新系APIは **テナント管理者権限のAPIキー** が必要です(一般ユーザーのAPIキーでは403等になります)。
|
||||
156
ClaudePleasanter/営業積算システム/apply-site-config.js
Normal file
156
ClaudePleasanter/営業積算システム/apply-site-config.js
Normal file
@ -0,0 +1,156 @@
|
||||
/**
|
||||
* apply-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* ★★★ このスクリプトは常にドライランです。実際のAPI送信は一切行いません。★★★
|
||||
*
|
||||
* Claudeが生成した desired_config.json(希望構成)を読み込み、
|
||||
* 1. 現状構成(siteSettingJsons/site-{SiteId}_latest.json)との差分表示
|
||||
* 2. 実際に送信されるはずの HTTPリクエスト(URL / Body)の表示
|
||||
* 3. そのまま使えるcurlコマンドの出力
|
||||
* のみを行います。実行(fetch送信)は行いません。
|
||||
*
|
||||
* 内容を確認した上で、問題なければ、
|
||||
* - 表示されたcurlコマンドを手動で実行する
|
||||
* - または、このファイル末尾の "実送信ブロック" のコメントを
|
||||
* 自分の判断で外して実行する
|
||||
* のいずれかで反映してください。
|
||||
*
|
||||
* desired_config.json の形式:
|
||||
* {
|
||||
* "Mode": "partial", // "partial" = updatesitesettings(部分更新) / "full" = updatesite(全体更新)
|
||||
* "SiteId": 12345, // 省略時は config.json の値を使用
|
||||
* "SiteSettings": { ... }, // 変更したい項目のみ(partialの場合)/ 全体(fullの場合)
|
||||
* "Title": "...", // full更新時のみ必要な場合あり
|
||||
* "ReferenceType": "...",
|
||||
* "ParentId": ...,
|
||||
* "InheritPermission": ...
|
||||
* }
|
||||
*
|
||||
* 参照した公式マニュアル:
|
||||
* - サイト更新(全体): https://pleasanter.org/manual/api-site-update
|
||||
* URL: POST {BaseUrl}/api/items/{SiteId}/updatesite
|
||||
* - サイト設定の部分更新: https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
* URL: POST {BaseUrl}/api/items/{SiteId}/updatesitesettings
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function loadJson(p, required = true) {
|
||||
if (!fs.existsSync(p)) {
|
||||
if (required) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${p}`);
|
||||
process.exit(1);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
// ざっくりした差分表示(キー単位・トップ1階層+SiteSettings1階層まで)
|
||||
// partialモード(updatesitesettings)は desired 側にあるキーだけがサーバーに送られ、
|
||||
// 記載のないキーは現状のまま変わらない。そのため比較対象は afterKeys のみに限定する
|
||||
// (beforeにしかないキーを「削除される」ように見せるのは誤り)。
|
||||
// fullモード(updatesite)はSiteSettings全体を丸ごと置き換えるため、beforeKeysも含めて比較する。
|
||||
function diffTopLevel(before, after, label, mode = "partial") {
|
||||
if (!before) {
|
||||
console.log(` (現状データなし。get-site-config.jsで先に取得しておくと差分表示できます)`);
|
||||
return;
|
||||
}
|
||||
const afterKeys = new Set(Object.keys(after || {}));
|
||||
const allKeys = mode === "full" ? new Set([...Object.keys(before || {}), ...afterKeys]) : afterKeys;
|
||||
|
||||
let changed = false;
|
||||
for (const key of allKeys) {
|
||||
const b = JSON.stringify(before?.[key]);
|
||||
const a = JSON.stringify(after?.[key]);
|
||||
if (b !== a) {
|
||||
changed = true;
|
||||
console.log(` [変更] ${label}.${key}`);
|
||||
console.log(` - 現状: ${truncate(b)}`);
|
||||
console.log(` + 希望: ${truncate(a)}`);
|
||||
}
|
||||
}
|
||||
if (!changed) console.log(" 差分なし");
|
||||
}
|
||||
|
||||
function truncate(str, max = 200) {
|
||||
if (str === undefined) return "(undefined)";
|
||||
return str.length > max ? str.slice(0, max) + "...(略)" : str;
|
||||
}
|
||||
|
||||
function maskApiKey(key) {
|
||||
if (!key) return key;
|
||||
return key.length > 8 ? key.slice(0, 4) + "****" + key.slice(-4) : "****";
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const baseDir = __dirname;
|
||||
const configPath = path.join(baseDir, "config.json");
|
||||
const desiredPath = process.argv[2] || path.join(baseDir, "desired_config.json");
|
||||
|
||||
const config = loadJson(configPath);
|
||||
const desired = loadJson(desiredPath);
|
||||
|
||||
const siteId = desired.SiteId || config.SiteId;
|
||||
const mode = desired.Mode || "partial";
|
||||
const endpoint = mode === "full" ? "updatesite" : "updatesitesettings";
|
||||
const url = `${config.BaseUrl.replace(/\/+$/, "")}/api/items/${siteId}/${endpoint}`;
|
||||
|
||||
const latestConfigPath = path.join(baseDir, "siteSettingJsons", `site-${siteId}_latest.json`);
|
||||
const current = loadJson(latestConfigPath, false);
|
||||
const currentSiteSettings =
|
||||
current?.Response?.Data?.SiteSettings ||
|
||||
current?.Response?.Site?.SiteSettings ||
|
||||
current?.Response?.SiteSettings ||
|
||||
null;
|
||||
|
||||
console.log("========================================");
|
||||
console.log(" ドライラン結果(実際の送信は行いません)");
|
||||
console.log("========================================");
|
||||
console.log(`Mode : ${mode} (${mode === "full" ? "全体更新" : "部分更新"})`);
|
||||
console.log(`URL : ${url}`);
|
||||
console.log(`SiteId : ${siteId}`);
|
||||
console.log("");
|
||||
console.log("--- SiteSettings 差分(現状 → 希望) ---");
|
||||
diffTopLevel(currentSiteSettings, desired.SiteSettings, "SiteSettings", mode);
|
||||
console.log("");
|
||||
|
||||
const body = {
|
||||
ApiVersion: config.ApiVersion || "1.1",
|
||||
ApiKey: config.ApiKey,
|
||||
...desired,
|
||||
};
|
||||
delete body.Mode; // Mode はエンドポイント選択にのみ使用し、送信Bodyには含めない
|
||||
|
||||
const maskedBody = { ...body, ApiKey: maskApiKey(body.ApiKey) };
|
||||
console.log("--- 送信されるはずのBody(APIキーはマスク表示) ---");
|
||||
console.log(JSON.stringify(maskedBody, null, 2));
|
||||
console.log("");
|
||||
|
||||
console.log("--- 手動実行用 curl コマンド(内容確認の上、必要ならご自身で実行してください) ---");
|
||||
console.log(
|
||||
`curl -X POST "${url}" -H "Content-Type: application/json" -d '${JSON.stringify(body)}'`
|
||||
);
|
||||
console.log("");
|
||||
console.log("[注意] このスクリプトはAPIへの送信を一切行っていません(ドライラン専用)。");
|
||||
console.log("内容に問題がなければ、上記curlコマンドを手動実行するか、");
|
||||
console.log("スクリプト内の「実送信ブロック」を有効化する改修をご自身の判断で行ってください。");
|
||||
|
||||
// ------------------------------------------------------------
|
||||
// 実送信ブロック(デフォルトでは絶対に到達しないようにガードしています)
|
||||
// 意図的に反映を自動化したい場合のみ、下のガードを外して使ってください。
|
||||
// ------------------------------------------------------------
|
||||
const ENABLE_ACTUAL_SEND = false; // ← 安全のため常にfalse固定。変更は自己責任で。
|
||||
if (ENABLE_ACTUAL_SEND) {
|
||||
const res = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
console.log(await res.text());
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
139
ClaudePleasanter/営業積算システム/build-desired-config.js
Normal file
139
ClaudePleasanter/営業積算システム/build-desired-config.js
Normal file
@ -0,0 +1,139 @@
|
||||
/**
|
||||
* build-desired-config.js
|
||||
* ------------------------------------------------------------
|
||||
* extract-site-config.js で分割した configs/site-{SiteId}/ 配下のファイル
|
||||
* (scripts/*.js, styles/*.css, serverscripts/*.js, processes.json)を
|
||||
* manifest.json を手がかりに再構成し、apply-site-config.js が読み込む
|
||||
* desired_config.json を生成します。
|
||||
*
|
||||
* ★★★ このスクリプトもAPIへは一切送信しません(ファイル生成のみ)。★★★
|
||||
* 反映は既存の運用どおり、
|
||||
* 1. node apply-site-config.js でドライラン確認(差分・送信Body・curl表示)
|
||||
* 2. 内容を確認し、問題なければ表示されたcurlコマンドを手動実行
|
||||
* の流れに乗せてください。
|
||||
*
|
||||
* 使い方:
|
||||
* node build-desired-config.js site-214812
|
||||
* (フォルダ名は "site-{SiteId}_{サイト名}" 形式だが、"site-{SiteId}" だけの
|
||||
* 前方一致指定でも configs/ 配下から自動的に該当フォルダを探して解決する)
|
||||
* node build-desired-config.js ./configs/site-214812_物件基本情報
|
||||
*
|
||||
* 出力: desired_config.json(このスクリプトと同じ階層。既存ファイルは上書き)
|
||||
*
|
||||
* 対応範囲:
|
||||
* Scripts / Styles / ServerScripts / Processes
|
||||
* → Mode:"partial"(updatesitesettings)で反映可能なため、そのまま組み込みます。
|
||||
* Html(GridGuide等のガイド)
|
||||
* → SiteSettingsではなくサイトデータ直下の項目のため、
|
||||
* 部分更新(updatesitesettings)には含められません。
|
||||
* 反映したい場合は Mode:"full"(updatesite)用の別JSONを用意し、
|
||||
* Title/ReferenceType等の必須項目とあわせて指定してください。
|
||||
* このスクリプトはhtml/配下に抽出物がある場合、警告のみ表示します。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function readJson(p) {
|
||||
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
||||
}
|
||||
|
||||
// フォルダ名は "site-{SiteId}_{サイト名}" 形式。"site-{SiteId}" のみの指定でも
|
||||
// configs/ 配下から前方一致で該当フォルダを探して解決する。
|
||||
// 新形式(サイト名付き)を優先し、裸の "site-{SiteId}" 完全一致(リネーム前の古い抽出結果の可能性)は
|
||||
// 他に候補が無い場合のみ後方互換フォールバックとして使う。
|
||||
function resolveSiteDir(baseDir, arg) {
|
||||
const configsDir = path.join(baseDir, "configs");
|
||||
const direct = path.isAbsolute(arg) ? arg : path.join(configsDir, path.basename(arg));
|
||||
const baseName = path.basename(arg);
|
||||
|
||||
const candidates = fs.existsSync(configsDir)
|
||||
? fs.readdirSync(configsDir).filter((name) => {
|
||||
const full = path.join(configsDir, name);
|
||||
return (
|
||||
fs.statSync(full).isDirectory() && (name === baseName || name.startsWith(`${baseName}_`))
|
||||
);
|
||||
})
|
||||
: [];
|
||||
|
||||
const suffixed = candidates.filter((name) => name !== baseName);
|
||||
if (suffixed.length === 1) return path.join(configsDir, suffixed[0]);
|
||||
if (suffixed.length > 1) {
|
||||
console.error(`[エラー] "${baseName}" に一致するフォルダが複数見つかりました: ${suffixed.join(", ")}`);
|
||||
console.error("フォルダ名をフルで指定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
if (candidates.includes(baseName)) return path.join(configsDir, baseName);
|
||||
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) return direct;
|
||||
|
||||
console.error(`[エラー] フォルダが見つかりません: ${direct}`);
|
||||
console.error("先に node extract-site-config.js(またはnode get-site-config.js)を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function rebuildList(manifestEntries, dir, bodyKey = "Body") {
|
||||
return (manifestEntries || []).map((entry) => {
|
||||
const { File, ...meta } = entry;
|
||||
const filePath = path.join(dir, File);
|
||||
if (!fs.existsSync(filePath)) {
|
||||
console.error(`[エラー] manifest.jsonに記載のファイルが見つかりません: ${filePath}`);
|
||||
process.exit(1);
|
||||
}
|
||||
const body = fs.readFileSync(filePath, "utf-8");
|
||||
return { ...meta, [bodyKey]: body };
|
||||
});
|
||||
}
|
||||
|
||||
function main() {
|
||||
const baseDir = __dirname;
|
||||
const arg = process.argv[2];
|
||||
if (!arg) {
|
||||
console.error("[エラー] 対象フォルダを指定してください(例: node build-desired-config.js site-214812)");
|
||||
process.exit(1);
|
||||
}
|
||||
const siteDir = resolveSiteDir(baseDir, arg);
|
||||
|
||||
const manifestPath = path.join(siteDir, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) {
|
||||
console.error(`[エラー] manifest.jsonが見つかりません: ${manifestPath}`);
|
||||
console.error("先に node extract-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
const manifest = readJson(manifestPath);
|
||||
|
||||
const siteSettings = {};
|
||||
|
||||
siteSettings.Scripts = rebuildList(manifest.Scripts, path.join(siteDir, "scripts"));
|
||||
siteSettings.Styles = rebuildList(manifest.Styles, path.join(siteDir, "styles"));
|
||||
siteSettings.ServerScripts = rebuildList(manifest.ServerScripts, path.join(siteDir, "serverscripts"));
|
||||
|
||||
const processesPath = path.join(siteDir, manifest.ProcessesFile || "processes.json");
|
||||
siteSettings.Processes = fs.existsSync(processesPath) ? readJson(processesPath) : [];
|
||||
|
||||
const desired = {
|
||||
Mode: "partial",
|
||||
SiteId: manifest.SiteId,
|
||||
SiteSettings: siteSettings,
|
||||
};
|
||||
|
||||
const outPath = path.join(baseDir, "desired_config.json");
|
||||
fs.writeFileSync(outPath, JSON.stringify(desired, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 生成しました: ${outPath}`);
|
||||
console.log(` Scripts : ${siteSettings.Scripts.length}件`);
|
||||
console.log(` Styles : ${siteSettings.Styles.length}件`);
|
||||
console.log(` ServerScripts : ${siteSettings.ServerScripts.length}件`);
|
||||
console.log(` Processes : ${siteSettings.Processes.length}件`);
|
||||
|
||||
if (manifest.Html && manifest.Html.length > 0) {
|
||||
console.log("\n[注意] html/ 配下のガイドHTMLは、この desired_config.json には含まれていません。");
|
||||
console.log(" GridGuide等はSiteSettingsではなくサイト直下の項目のため、部分更新(updatesitesettings)では送れません。");
|
||||
console.log(' 反映するには Mode:"full"(updatesite)用の別JSONを用意し、Title等の必須項目とあわせて指定してください。');
|
||||
}
|
||||
|
||||
console.log("\n次のステップ: node apply-site-config.js でドライラン確認 → 内容確認の上、表示されたcurlコマンドを手動実行してください。");
|
||||
}
|
||||
|
||||
main();
|
||||
6
ClaudePleasanter/営業積算システム/config.example.json
Normal file
6
ClaudePleasanter/営業積算システム/config.example.json
Normal file
@ -0,0 +1,6 @@
|
||||
{
|
||||
"BaseUrl": "http://your-pleasanter-server/",
|
||||
"SiteId": "12345,23456",
|
||||
"ApiKey": "ここに発行したAPIキーを貼り付け",
|
||||
"ApiVersion": "1.1"
|
||||
}
|
||||
@ -0,0 +1,56 @@
|
||||
# 概要書:①物件基本情報 Ver0.3
|
||||
|
||||
- この資料は業務担当者向けに、システム上の構成をもとに「何を管理し、どのような流れで使うか」を整理したものです。
|
||||
|
||||
- 技術仕様の詳細は [./site-335410_①物件基本情報 Ver0.3_spec.md](./site-335410_①物件基本情報 Ver0.3_spec.md) を参照してください。
|
||||
|
||||
|
||||
## 1. これはどんな画面か
|
||||
|
||||
このサイトは「①物件基本情報 Ver0.3」を管理するための業務画面です。Resultsとして扱われ、登録・照会・更新の基本的な業務フローに沿って利用されます。
|
||||
|
||||
- 画面上の案内では、新規物件は右上の「+新規作成」ボタンから登録してください。 見積依頼は「新規見積依頼」ボタンから依頼を進めてください。 ■LINEWORKS:[営業積算相談窓口](https://line.worksmobile.com/message/bot/view?version=10&botNo=11910622) ※窓口の対応内容や対応時間の確認は[こちら](https://board.worksmobile.com/main/article/408000000078578285...
|
||||
|
||||
## 2. 主に入力・参照する情報
|
||||
|
||||
| 項目 | 用途 |
|
||||
| --- | --- |
|
||||
| 登録状況 | |
|
||||
| 管理番号 | |
|
||||
| 登録者 | |
|
||||
| 年 | |
|
||||
| 契約コード | |
|
||||
| エリア記号 | |
|
||||
| ブランド記号 | |
|
||||
| 承認図予定日 | |
|
||||
| 着工予定日 | |
|
||||
| 備考欄 | |
|
||||
| 添付ファイル | |
|
||||
| 変更履歴 | |
|
||||
|
||||
|
||||
## 3. 業務でよく行う操作
|
||||
|
||||
| 操作 | 状態の変化 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| 重複チェック | -1 → -1 | 入力内容や整合性を確認する操作です。 |
|
||||
| 入力必須 | -1 → -1 | 業務状態を遷移させるための操作です。 |
|
||||
|
||||
|
||||
## 4. 他の管理表とのつながり
|
||||
|
||||
- 210699 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 109100 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 96243 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 189112 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
## 5. 利用時のポイント
|
||||
|
||||
- まずは画面上の入力項目を確認して、必要な情報を登録します。
|
||||
|
||||
- 進捗や状態はステータス項目で管理し、必要に応じて他サイトの情報を参照します。
|
||||
|
||||
- 重要な更新は履歴や添付情報と合わせて確認し、引き継ぎや問い合わせに備えます。
|
||||
Binary file not shown.
@ -0,0 +1,229 @@
|
||||
# サイト仕様書:①物件基本情報 Ver0.3(SiteId: 335410)
|
||||
|
||||
|
||||
- 取得元データ: [site-335410_latest.json](./site-335410_latest.json)
|
||||
|
||||
- 取得日時: 2026-07-05T17:28:21
|
||||
|
||||
- 設定バージョン: 1.017
|
||||
|
||||
|
||||
## 1. サイト基本情報
|
||||
|
||||
| 項目 | 値 |
|
||||
| --- | --- |
|
||||
| TenantId | 1 |
|
||||
| Title | ①物件基本情報 Ver0.3 |
|
||||
| ReferenceType | Results |
|
||||
| ParentId | 74663 |
|
||||
| InheritPermission | 335410 |
|
||||
| Publish | false |
|
||||
| DisableCrossSearch | false |
|
||||
| Creator | 737 |
|
||||
| Updator | 460 |
|
||||
| CreatedTime | 2026-03-10T13:41:04 |
|
||||
| UpdatedTime | 2026-07-05T17:28:21 |
|
||||
| SiteSettings.Version | 1.017 |
|
||||
|
||||
|
||||
## 2. アクセス権限(Permissions)
|
||||
|
||||
| 種別 | 対象 | 権限値 |
|
||||
| --- | --- | --- |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
| | | |
|
||||
|
||||
|
||||
※ 権限値は環境のロール定義に依存します。
|
||||
|
||||
## 3. 画面構成
|
||||
|
||||
### 3.1 一覧画面(GridColumns)
|
||||
|
||||
| 順番 | 列名 | 表示ラベル |
|
||||
| --- | --- | --- |
|
||||
| 1 | | |
|
||||
| 2 | | |
|
||||
| 3 | | |
|
||||
| 4 | | |
|
||||
| 5 | | |
|
||||
| 6 | | |
|
||||
| 7 | | |
|
||||
| 8 | | |
|
||||
| 9 | | |
|
||||
| 10 | | |
|
||||
|
||||
|
||||
### 3.2 編集画面レイアウト(EditorColumnHash / Sections)
|
||||
|
||||
| タブ/キー | 項目一覧 |
|
||||
| --- | --- |
|
||||
| General | ResultId, Ver, Title, Comments, ClassA, ClassB, Status, ClassF, _Section-1, ClassY, ClassZ, ClassS, _Section-2, Class001, Class002, Class003, Class004, _Section-7, Class005, Class006, Class007, Class008, _Section-8, Class009, Class010, Class011, Class012, ClassK, Class015, _Section-9, Class021, Class022, Class023, Class024, Class025, ClassM, ClassN, _Section-4, Date001, Date002, _Section-3, Class031, Class032, Class033, Class034, Class035, Class036, Class037, Class038, Class151, _Section-6, Class061, Class062, Class063, Class064, Description151, Class152, Class153, _Section-5, DescriptionA, AttachmentsA, Description150, Class200, _Links-335411 |
|
||||
|
||||
|
||||
|
||||
#### Sections
|
||||
|
||||
| Id | ラベル | AllowExpand | Expand |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | | false | true |
|
||||
| 2 | 物件情報 | false | true |
|
||||
| 7 | | false | true |
|
||||
| 8 | | false | true |
|
||||
| 9 | | false | true |
|
||||
| 4 | スケジュール ※商談中の場合は、仮予定日を選択してください(後から変更可) | false | true |
|
||||
| 3 | 物件担当者 | false | true |
|
||||
| 6 | CCで送ってほしい社員・パートがいる場合は、名前を検索・選択してください | false | true |
|
||||
| 5 | その他 ※物件担当者間で共有したいことがありましたらこちらへ | false | true |
|
||||
|
||||
|
||||
### 3.3 タイトル表示(TitleColumns / TitleSeparator)
|
||||
|
||||
- タイトル項目: ClassA + Class022 + Class023
|
||||
|
||||
- 区切り文字: ■
|
||||
|
||||
## 4. 項目定義(Columns)
|
||||
|
||||
| 列名(内部) | 種別 | 表示ラベル | 説明/入力ガイド | 備考 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| Comments | 不明 | 変更履歴 | | |
|
||||
| Status | 状態 | 登録状況 | | |
|
||||
| ClassA | 文字列 | 管理番号 | | NoWrap, ExtendedHtmlAfterControl |
|
||||
| ClassB | 文字列 | 登録者 | | |
|
||||
| ClassF | 文字列 | 年 | | |
|
||||
| ClassZ | 文字列 | 契約コード | | |
|
||||
| ClassS | 文字列 | 案件状況 | | NoWrap |
|
||||
| Class001 | 文字列 | 施主1・姓 | | NoWrap |
|
||||
| Class002 | 文字列 | 施主1・名 | | |
|
||||
| Class003 | 文字列 | 施主1・姓(カナ) | | NoWrap |
|
||||
| Class004 | 文字列 | 施主1・名(カナ) | | |
|
||||
| Class005 | 文字列 | 施主2・姓 | | NoWrap |
|
||||
| Class006 | 文字列 | 施主2・名 | | |
|
||||
| Class010 | 文字列 | 施主3・名 | | |
|
||||
| Class012 | 文字列 | 施主3・名(カナ) | | |
|
||||
| ClassM | 文字列 | エリア記号 | | NoWrap |
|
||||
| ClassN | 文字列 | ブランド記号 | | |
|
||||
| Date001 | 日付 | 承認図予定日 | | |
|
||||
| Date002 | 日付 | 着工予定日 | | |
|
||||
| DescriptionA | 説明 | 備考欄 | ※こちらの内容は営業積算課へ通知されません | ExtendedHtmlAfterControl |
|
||||
| AttachmentsA | 添付 | 添付ファイル | | |
|
||||
| Description150 | 説明 | 変更履歴 | | |
|
||||
| Class200 | 文字列 | 重複チェック | | |
|
||||
| ClassK | 文字列 | 施主名 | 施主:姓名を結合 | NoWrap |
|
||||
| UpdatedTime | 不明 | | | |
|
||||
| Class061 | 文字列 | CC① | | |
|
||||
| Class062 | 文字列 | CC② | | |
|
||||
| Class063 | 文字列 | CC③ | | NoWrap |
|
||||
| Class064 | 文字列 | CC④ | | |
|
||||
| Class031 | 文字列 | 契約営業所 | | |
|
||||
| Class032 | 文字列 | 主担当営業 | | NoWrap |
|
||||
| Class033 | 文字列 | 副担当営業 | | |
|
||||
| Class034 | 文字列 | 営業設計 | | NoWrap |
|
||||
| Class035 | 文字列 | 管理設計 | | |
|
||||
| Class036 | 文字列 | 担当IC | | NoWrap |
|
||||
| Class037 | 文字列 | 担当監督 | | |
|
||||
| Class038 | 文字列 | 所属長 | | NoWrap |
|
||||
| Class007 | 文字列 | 施主2・姓(カナ) | | NoWrap |
|
||||
| Class008 | 文字列 | 施主2・名(カナ) | | |
|
||||
| Class009 | 文字列 | 施主3・姓 | | NoWrap |
|
||||
| Class022 | 文字列 | エリア | | |
|
||||
| Class011 | 文字列 | 施主3・姓(カナ) | | NoWrap |
|
||||
| Class023 | 文字列 | 契約ブランド | | NoWrap |
|
||||
| Class024 | 文字列 | 防火指定 | | NoWrap |
|
||||
| Class025 | 文字列 | 建物種類 | | |
|
||||
| Class021 | 文字列 | 建上地 | | FieldCss |
|
||||
| Class040 | 文字列 | | | |
|
||||
| ClassY | 文字列 | マスターシート | | NoWrap, FieldCss, ExtendedHtmlAfterControl |
|
||||
| Class015 | 文字列 | 施主名マージ | | NoWrap, FieldCss |
|
||||
| Class151 | 文字列 | 担当者一覧 | | FieldCss |
|
||||
| Class152 | 文字列 | CC一覧 | | FieldCss |
|
||||
| Class153 | 文字列 | 担当者・CC一覧 | | FieldCss |
|
||||
|
||||
|
||||
## 4.1 項目ごとの補足HTML(ExtendedHtmlAfterControl)
|
||||
|
||||
| 列名 | ラベル | 内容 |
|
||||
| --- | --- | --- |
|
||||
| ClassA | 管理番号 | <div class="commentAdd small blue">
|
||||
※自動採番
|
||||
</div> |
|
||||
| DescriptionA | 備考欄 | <div class="commentAdd red">
|
||||
※こちらの内容は営業積算課へは通知されません
|
||||
</div> |
|
||||
| ClassY | マスターシート | <div class="commentAdd red nowrap">
|
||||
※マスターシート登録済み物件は、上記より選択し作成ボタンを押すと登録情報が自動反映されます
|
||||
</div> |
|
||||
|
||||
|
||||
## 7. 他サイト連携(Links / ルックアップ)
|
||||
|
||||
| 起点列 | 参照先SiteId | 参照先の値→コピー先 | JsonFormat |
|
||||
| --- | --- | --- | --- |
|
||||
| | 210699 | | true |
|
||||
| | 109100 | | true |
|
||||
| | 96243 | | true |
|
||||
| | 189112 | | true |
|
||||
|
||||
|
||||
## 8. プロセス設定(Processes)
|
||||
|
||||
- 定義ファイル: [configs/configs/site-335410_①物件基本情報 Ver0.3/processes.json]
|
||||
|
||||
| Id | Name | DisplayName | 実行条件(Current→Changed) | 動作 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 3 | 重複チェック | 重複チェック | | |
|
||||
| 4 | 入力必須 | 入力必須 | | |
|
||||
|
||||
|
||||
## 9. スタイル(Styles)
|
||||
|
||||
| Id | Title | ファイル | 備考 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 01.読み取り専用 | [1_01.読み取り専用.css](./site-335410_①物件基本情報 Ver0.3/styles/1_01.読み取り専用.css) | |
|
||||
| 2 | 02.共通 | [2_02.共通.css](./site-335410_①物件基本情報 Ver0.3/styles/2_02.共通.css) | |
|
||||
|
||||
|
||||
## 10. スクリプト(Scripts)/サーバースクリプト(ServerScripts)
|
||||
|
||||
### Scripts
|
||||
|
||||
| Id | Title | ファイル | 要約 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | test(ページ遷移) | [1_test(ページ遷移).js](./site-335410_①物件基本情報 Ver0.3/scripts/1_test(ページ遷移).js) | $p.events.after_send_Control_1 = function () { |
|
||||
| 2 | サンプル④担当者マージ:NG6/21 | [2_サンプル④担当者マージ:NG6_21.js](./site-335410_①物件基本情報 Ver0.3/scripts/2_サンプル④担当者マージ:NG6_21.js) | //---------------------------------------------------------------------- |
|
||||
| 3 | test①施主名マージ:OK6/21 | [3_test①施主名マージ:OK6_21.js](./site-335410_①物件基本情報 Ver0.3/scripts/3_test①施主名マージ:OK6_21.js) | //---------------------------------------------------------------------- |
|
||||
| 4 | 01_基本スクリプト.js | [4_01_基本スクリプト.js.js](./site-335410_①物件基本情報 Ver0.3/scripts/4_01_基本スクリプト.js.js) | /* |
|
||||
|
||||
|
||||
## 11. その他設定
|
||||
|
||||
| 設定キー | 値 |
|
||||
| --- | --- |
|
||||
|
||||
| Version | 1.017 |
|
||||
|
||||
| ReferenceType | Results |
|
||||
|
||||
| FilterColumns | Status, ClassB, Class022, Class031, Class032, Class033, Class034, Class035, Class036, Class037 |
|
||||
|
||||
| GeneralTabLabelText | 物件情報 |
|
||||
|
||||
| TabLatestId | 1 |
|
||||
|
||||
| SectionLatestId | 9 |
|
||||
|
||||
| StatusControls | [object Object] |
|
||||
|
||||
| AutoVerUpType | 3 |
|
||||
|
||||
| AllowCopy | false |
|
||||
|
||||
| HideLink | true |
|
||||
|
||||
| NoDisplayIfReadOnly | false |
|
||||
|
||||
| NotInheritPermissionsWhenCreatingSite | false |
|
||||
@ -0,0 +1,71 @@
|
||||
# 概要書:②問い合わせ内容管理 Ver0.3
|
||||
|
||||
- この資料は業務担当者向けに、システム上の構成をもとに「何を管理し、どのような流れで使うか」を整理したものです。
|
||||
|
||||
- 技術仕様の詳細は [./site-335411_②問い合わせ内容管理 Ver0.3_spec.md](./site-335411_②問い合わせ内容管理 Ver0.3_spec.md) を参照してください。
|
||||
|
||||
|
||||
## 1. これはどんな画面か
|
||||
|
||||
このサイトは「②問い合わせ内容管理 Ver0.3」を管理するための業務画面です。Resultsとして扱われ、登録・照会・更新の基本的な業務フローに沿って利用されます。
|
||||
|
||||
- 画面上の案内では、新規依頼は【①物件登録情報】の各施主ページ内から行ってください! ※②問い合わせ内容管理ページの「+新規作成」から依頼するとエラーが出ます ■LINEWORKS:[営業積算相談窓口](https://line.worksmobile.com/message/bot/view?version=10&botNo=11910622) ※窓口の対応内容や対応時間の確認は[こちら](https://board.worksmobile.com/main/article/40800000...
|
||||
|
||||
## 2. 主に入力・参照する情報
|
||||
|
||||
| 項目 | 用途 |
|
||||
| --- | --- |
|
||||
| 親番 | |
|
||||
| 主担当営業担当 | |
|
||||
| アップ希望時間 | |
|
||||
| 事前相談者 | |
|
||||
| 現在の進捗 | |
|
||||
| 現場の進捗 | |
|
||||
| 必要な見積金額 | |
|
||||
| アップ希望日 | |
|
||||
| 問い合わせ経緯 | |
|
||||
| 承認図予定日 | |
|
||||
| 着工予定日 | |
|
||||
| 受付日 | |
|
||||
| 依頼者 | この案件の質疑応答で「回答する側」となる本人(既定では案件作成者) |
|
||||
| 質疑内容 | 営業積算課が依頼者に確認したい内容を入力する欄(送信すると欄はクリアされ、下部の履歴に記録される) |
|
||||
| 回答内容 | 依頼者が質疑への回答を入力する欄(送信すると欄はクリアされ、下部の履歴に記録される) |
|
||||
| 質疑回答ステータス | 質疑なし/質疑あり/回答あり/回答確認済み、の4段階で今の状態を表す |
|
||||
| 質疑ファイル/回答ファイル | 質疑・回答それぞれに添付できるファイル欄 |
|
||||
| 質疑送付先 | 質疑を送る相手(複数選択可)を指定する欄。履歴の表示に使われる |
|
||||
| 質疑回答期日 | 質疑送信時に自動計算される回答期限(土日祝を除く5営業日後) |
|
||||
| 回答確認日 | 営業積算課が回答内容を確認した日付(自動記録) |
|
||||
| 質疑回答送信者 | 直近に質疑または回答を送信した人の氏名(自動記録) |
|
||||
|
||||
|
||||
## 3. 質疑応答の業務の流れ(ボタン操作)
|
||||
|
||||
営業積算課と依頼者との間で、案件についての質疑・回答をやり取りするための機能です。やり取りは画面下部に時系列の一覧表として表示され、質疑は赤系、回答は黄色系の色で区別されます。
|
||||
|
||||
| 操作 | 誰が行うか | 状態の変化 | 説明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 質疑送信 | 営業積算課 | 受付済~社内確認中 → 依頼者質疑中 | 質疑内容を入力して送信すると、依頼者へ確認を求める状態になります。回答期日(送信日から土日祝を除く5営業日後)が自動的に設定されます |
|
||||
| 回答送信 | 依頼者本人のみ | (状態はそのまま) | 依頼者が回答内容を入力して送信します。ボタンは依頼者本人にしか表示・実行できません |
|
||||
| 回答確認 | 営業積算課 | 依頼者質疑中 → 積算対応中 | 依頼者からの回答内容を確認すると、案件は通常の対応状態に戻ります。確認した日付が記録されます |
|
||||
| 質疑回答削除 | 営業積算課 | (状態はそのまま。依頼者質疑中の間のみ操作可) | 誤って送信した質疑・回答を、履歴のチェックボックスで選んで削除(無効化)します |
|
||||
|
||||
※「質疑送信」ボタンは、案件のステータスが「受付済」〜「社内確認中」のいずれかの間だけ表示されます。
|
||||
|
||||
|
||||
## 4. 他の管理表とのつながり
|
||||
|
||||
- 210699 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 109100 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 335410 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
- 335450 の情報を参照・反映する連携です。関連するマスタや別サイトの情報を参照する連携です。
|
||||
|
||||
## 5. 利用時のポイント
|
||||
|
||||
- まずは画面上の入力項目を確認して、必要な情報を登録します。
|
||||
|
||||
- 進捗や状態はステータス項目で管理し、必要に応じて他サイトの情報を参照します。
|
||||
|
||||
- 重要な更新は履歴や添付情報と合わせて確認し、引き継ぎや問い合わせに備えます。
|
||||
Binary file not shown.
@ -0,0 +1,308 @@
|
||||
# サイト仕様書:②問い合わせ内容管理 Ver0.3(SiteId: 335411)
|
||||
|
||||
|
||||
- 取得元データ: [site-335411_latest.json](./site-335411_latest.json)
|
||||
|
||||
- 取得日時: 2026-07-05T18:20:51
|
||||
|
||||
- 設定バージョン: 1.017
|
||||
|
||||
|
||||
## 1. サイト基本情報
|
||||
|
||||
| 項目 | 値 |
|
||||
| --- | --- |
|
||||
| TenantId | 1 |
|
||||
| Title | ②問い合わせ内容管理 Ver0.3 |
|
||||
| ReferenceType | Results |
|
||||
| ParentId | 74663 |
|
||||
| InheritPermission | 74663 |
|
||||
| Publish | false |
|
||||
| DisableCrossSearch | false |
|
||||
| Creator | 737 |
|
||||
| Updator | 460 |
|
||||
| CreatedTime | 2026-03-10T13:41:38 |
|
||||
| UpdatedTime | 2026-07-05T18:20:51 |
|
||||
| SiteSettings.Version | 1.017 |
|
||||
|
||||
|
||||
## 3. 画面構成
|
||||
|
||||
### 3.1 一覧画面(GridColumns)
|
||||
|
||||
| 順番 | 列名 | 表示ラベル |
|
||||
| --- | --- | --- |
|
||||
| 1 | | |
|
||||
| 2 | | |
|
||||
| 3 | | |
|
||||
| 4 | | |
|
||||
| 5 | | |
|
||||
| 6 | | |
|
||||
| 7 | | |
|
||||
| 8 | | |
|
||||
| 9 | | |
|
||||
| 10 | | |
|
||||
| 11 | | |
|
||||
| 12 | | |
|
||||
| 13 | | |
|
||||
| 14 | | |
|
||||
|
||||
|
||||
### 3.2 編集画面レイアウト(EditorColumnHash / Sections)
|
||||
|
||||
| タブ/キー | 項目一覧 |
|
||||
| --- | --- |
|
||||
| General | ResultId, Ver, Title, ClassZ, Comments, ClassA, ClassB, ClassX, ClassC, ClassD, _Section-1, DateA, DateJ, ClassE, CheckA, ClassF, ClassG, ClassH, ClassI, DescriptionA, DateB, DateC, _Section-2, Class001, Class002, Class003, Class004, Class005, Class006, _Section-3, Class031, Class032, Class033, Class034, Description031, Class045, Class050, _Section-17, Class038, Class039, Class040, DescriptionC, _Section-18, Attachments031, Class052, _Section-16, Class055, Class056, Attachments055, _Section-6, Date061, Class061, Class062, Date062, Class063, _Section-7, Date063, Class065, Description061, Description062 |
|
||||
| _Tab-2 | _Section-11, Date081, Class081, Date082, Class082, Class083, Class084, Status, Class080, _Section-12, Class085, Class086, Date084, Class087, Date085, Description081, Description083, _Section-13, Date090, Class091, Description091, Description092, Attachments101, Num101, Date101, Class101, Date102, Class102, Date103, Class103, Class095, Description095, Description101 |
|
||||
| _Tab-1 | Class071, Description071, Class075, Date071, _Section-8, _Section-9, Description075, Attachments071, Date073, Class077, Num071, _Section-10, Description076, Attachments072, Num072, Date074 |
|
||||
| _Tab-3 | _Section-14, Description111, Attachments111, Attachments112, Class111, Attachments113, _Section-15, Date111 |
|
||||
|
||||
|
||||
|
||||
#### Sections
|
||||
|
||||
| Id | ラベル | AllowExpand | Expand |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 案件情報 | true | true |
|
||||
| 2 | 物件情報 | false | true |
|
||||
| 3 | 問い合わせ内容 ※入力不備があった場合は差し戻します※ | false | true |
|
||||
| 17 | | false | true |
|
||||
| 18 | | false | true |
|
||||
| 16 | 不備・追加ある場合はこちら ※必ずコメント欄へ追加や変更がある旨記載してください | false | false |
|
||||
| 6 | 営業積算課 ※※ここから下は営業積算タブから参照し入力されるように設定予定のため、基本は読み取り専用にします※※ | false | true |
|
||||
| 7 | 見積回答結果 | false | true |
|
||||
| 11 | 受付処理 | false | true |
|
||||
| 12 | 遅延連絡 | false | true |
|
||||
| 13 | 算出回答結果 | false | true |
|
||||
| 8 | 質疑回答ログ ※※詳細未設定のため、ログは表示されません※※ | false | true |
|
||||
| 9 | 営業積算課からの質疑 | false | true |
|
||||
| 10 | 依頼者からの回答 | false | true |
|
||||
| 14 | 営業積算課からの引継ぎ | false | true |
|
||||
| 15 | 積算発注課管理 | false | true |
|
||||
|
||||
|
||||
### 3.3 タイトル表示(TitleColumns / TitleSeparator)
|
||||
|
||||
- タイトル項目: ClassA + ClassB + Class003 + Class004
|
||||
|
||||
- 区切り文字: -
|
||||
|
||||
## 4. 項目定義(Columns)
|
||||
|
||||
| 列名(内部) | 種別 | 表示ラベル | 説明/入力ガイド | 備考 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ClassA | 文字列 | 親番 | | NoWrap |
|
||||
| Owner | ユーザー | | | NoWrap |
|
||||
| ClassD | 文字列 | 主担当営業担当 | | |
|
||||
| ClassE | 文字列 | アップ希望時間 | | NoWrap |
|
||||
| ClassF | 文字列 | 事前相談者 | 事前相談した担当を選択してください | |
|
||||
| ClassG | 文字列 | 現在の進捗 | | NoWrap |
|
||||
| ClassH | 文字列 | 現場の進捗 | | NoWrap |
|
||||
| ClassI | 文字列 | 必要な見積金額 | | NoWrap |
|
||||
| DateA | 日付 | アップ希望日 | | |
|
||||
| CheckA | チェック | 事前相談済み | | NoWrap |
|
||||
| DescriptionA | 説明 | 問い合わせ経緯 | | ExtendedHtmlAfterControl |
|
||||
| DateB | 日付 | 承認図予定日 | | |
|
||||
| DateC | 日付 | 着工予定日 | | |
|
||||
| Class001 | 文字列 | 施主名 | | FieldCss |
|
||||
| Class002 | 文字列 | 建上地 | | FieldCss |
|
||||
| Class003 | 文字列 | エリア | | |
|
||||
| Class004 | 文字列 | ブランド | | |
|
||||
| Class005 | 文字列 | 建物種類 | | NoWrap |
|
||||
| Class006 | 文字列 | 防火指定 | | |
|
||||
| Status | 状態 | | | NoWrap |
|
||||
| Class031 | 文字列 | メーカー | | FieldCss |
|
||||
| Class038 | 文字列 | 積算Ver. | 該当の積算バージョンを入力してください | NoWrap, ExtendedHtmlAfterControl |
|
||||
| Class040 | 文字列 | nexthouseシリーズ | nexthouse提案の場合は選択してください | NoWrap |
|
||||
| Class045 | 文字列 | 標準仕様 | 標準仕様からの変更差額を希望の場合は、該当の仕様をこちらへ入力してください | FieldCss, ExtendedHtmlAfterControl |
|
||||
| Class050 | 文字列 | 前回の管理番号 | 既出問い合わせからの変更の場合は、該当の番号を選択してください | FieldCss |
|
||||
| Class052 | 文字列 | 回答希望送付先 | | FieldCss, ExtendedHtmlAfterControl |
|
||||
| Class061 | 文字列 | 受付者 | | |
|
||||
| Date061 | 日付 | 受付日 | | |
|
||||
| Date062 | 日付 | 見積依頼日 | | NoWrap |
|
||||
| Class062 | 文字列 | 状況 | | NoWrap |
|
||||
| Class063 | 文字列 | 見積依頼先 | | FieldCss |
|
||||
| Date063 | 日付 | 見積受信日 | | |
|
||||
| Class064 | 文字列 | 採用状況 | | NoWrap |
|
||||
| Class065 | 文字列 | 見積種別 | | NoWrap |
|
||||
| Description061 | 説明 | 見積結果 | | |
|
||||
| Description062 | 説明 | 回答ファイル | | |
|
||||
| Date071 | 日付 | 質疑回答送信日時 | | |
|
||||
| Date073 | 日付 | 質疑回答期日 | | |
|
||||
| Date074 | 日付 | 回答確認日 | | NoWrap |
|
||||
| Description071 | 説明 | 質疑回答データ保存用 | | |
|
||||
| Description075 | 説明 | 質疑内容 | | |
|
||||
| Description076 | 説明 | 回答内容 | | |
|
||||
| Attachments071 | 添付 | 質疑ファイル | | |
|
||||
| Attachments072 | 添付 | 回答ファイル | | |
|
||||
| Class071 | 文字列 | 質疑回答ステータス | | |
|
||||
| Class075 | 文字列 | 質疑回答送信者 | | |
|
||||
| Date081 | 日付 | 受付日 | | |
|
||||
| Date082 | 日付 | 見積依頼日 | | NoWrap |
|
||||
| Date085 | 日付 | 返信予定日 | | NoWrap |
|
||||
| Date090 | 日付 | 見積受信日 | | |
|
||||
| Date101 | 日付 | 算出日 | | NoWrap |
|
||||
| Date102 | 日付 | チェック日 | | NoWrap |
|
||||
| Date103 | 日付 | 返信日 | | NoWrap |
|
||||
| Class081 | 文字列 | 受付者 | | |
|
||||
| Class082 | 文字列 | 見積依頼者 | | |
|
||||
| Class083 | 文字列 | 見積依頼先 | | FieldCss |
|
||||
| Class084 | 文字列 | 見積備考 | | FieldCss |
|
||||
| Class085 | 文字列 | 遅延送付先 | | FieldCss |
|
||||
| Class086 | 文字列 | 遅延理由 | | |
|
||||
| Class087 | 文字列 | 遅延連絡者 | | |
|
||||
| Description081 | 説明 | 遅延連絡内容 | | |
|
||||
| Description083 | 説明 | 遅延連絡ログ | | |
|
||||
| Description091 | 説明 | 見積回答 | | |
|
||||
| Description092 | 説明 | 別途費用・伝達事項 | | |
|
||||
| Class101 | 文字列 | 算出者 | | |
|
||||
| Class102 | 文字列 | チェック者 | | |
|
||||
| Class103 | 文字列 | 返信者 | | |
|
||||
| Description101 | 説明 | 見積回答データ保存用 | | |
|
||||
| Attachments101 | 添付 | 見積回答ファイル | | |
|
||||
| Attachments111 | 添付 | NET見積原本 | | |
|
||||
| Attachments112 | 添付 | 算出根拠資料 | | |
|
||||
| Class111 | 文字列 | 採用書類 | | FieldCss |
|
||||
| Attachments113 | 添付 | その他 | | |
|
||||
| Description111 | 説明 | 引継ぎ備考 | | |
|
||||
| Num071 | 数値 | 質疑回数 | | |
|
||||
| Num072 | 数値 | 回答回数 | | |
|
||||
| Num101 | 数値 | 算出回答回数 | | NoWrap |
|
||||
| Date064 | 日付 | 使用期限 | | ExtendedHtmlAfterControl |
|
||||
| Class055 | 文字列 | 不備・追加 | | |
|
||||
| Class056 | 文字列 | 不備・追加理由 | | NoWrap |
|
||||
| Attachments055 | 添付 | 不備・追加ファイル | | |
|
||||
| Attachments031 | 添付 | 依頼ファイル | | |
|
||||
| Date111 | 日付 | データ出力日 | | |
|
||||
| ClassC | 文字列 | 依頼者 | | NoWrap |
|
||||
| Class091 | 文字列 | 見積種別 | | NoWrap |
|
||||
| Date091 | 日付 | 使用期限 | | ExtendedHtmlAfterControl |
|
||||
| Class095 | 文字列 | 回答送付先 | | FieldCss |
|
||||
| Description095 | 説明 | 回答送付先一覧 | | |
|
||||
| Class077 | 文字列 | 質疑送付先 | | FieldCss |
|
||||
| UpdatedTime | 不明 | | | |
|
||||
| ClassO | 文字列 | | | |
|
||||
| DescriptionC | 説明 | | 依頼内容マージ | |
|
||||
| DescriptionF | 説明 | | | |
|
||||
| Class032 | 文字列 | 商品名 | 正しい商品名を入力してください(例)イエリア 格子間仕切 | FieldCss |
|
||||
| Class033 | 文字列 | 品番 | 正しい品番を入力してください(例)QW404-■■ | FieldCss |
|
||||
| Description031 | 説明 | 依頼内容補足 | メーカー・商品名・品番以外に補足がある場合は、こちらに詳細を入力してください。 | |
|
||||
| Class080 | 文字列 | 問い合わせジャンル | | NoWrap |
|
||||
| Title | タイトル | レコード名 | | |
|
||||
| Class039 | 文字列 | マトリックスサイズ | | NoWrap |
|
||||
| ClassZ | 文字列 | テスト・リンク用 | | FieldCss |
|
||||
| Class034 | 文字列 | 施工範囲・箇所 | 施工場所を入力してください ※複数パターンある場合は『A:●●、B:■■』のようにわかるように記載してください | FieldCss |
|
||||
| Date084 | 日付 | 遅延連絡日 | | NoWrap |
|
||||
| DateJ | 日付 | アップ希望期限 | | |
|
||||
| ClassB | 文字列 | 子番 | | |
|
||||
| ClassX | 文字列 | 連番処理 | | NoWrap |
|
||||
|
||||
|
||||
## 4.1 項目ごとの補足HTML(ExtendedHtmlAfterControl)
|
||||
|
||||
| 列名 | ラベル | 内容 |
|
||||
| --- | --- | --- |
|
||||
| DescriptionA | 問い合わせ経緯 | <div class="commentAdd red nowrap">
|
||||
※承認図以降の問い合わせの場合は、「承認図時の内容」であっても必ず経緯を入力してください
|
||||
</div> |
|
||||
| Class038 | 積算Ver. | <div class="commentAdd red nowrap">
|
||||
※入力漏れ多いので注意!
|
||||
</div> |
|
||||
| Class045 | 標準仕様 | <div class="commentAdd red nowrap">
|
||||
※標準仕様からの変更差額出しを希望する場合は、こちらに標準仕様を記載してください
|
||||
</div> |
|
||||
| Class052 | 回答希望送付先 | <div class="commentAdd blue nowrap">
|
||||
※見積回答を送付して欲しい方を選択してください(選択ない場合は、依頼者と担当営業に回答送信します)
|
||||
</div> |
|
||||
| Date064 | 使用期限 | <div class="commentAdd red nowrap">
|
||||
※他物件使用ルールに基づいて使用してください
|
||||
</div> |
|
||||
| Date091 | 使用期限 | <div class="commentAdd red nowrap">
|
||||
※他物件使用ルールに基づいて使用してください
|
||||
</div> |
|
||||
|
||||
|
||||
## 7. 他サイト連携(Links / ルックアップ)
|
||||
|
||||
| 起点列 | 参照先SiteId | 参照先の値→コピー先 | JsonFormat |
|
||||
| --- | --- | --- | --- |
|
||||
| | 210699 | | |
|
||||
| | 109100 | | |
|
||||
| | 335410 | | true |
|
||||
| | 335450 | | true |
|
||||
|
||||
|
||||
## 8. プロセス設定(Processes)
|
||||
|
||||
質疑応答機能(下記10.のScripts「03_質疑応答スクリプト」と対)として、2026-07-11時点で4件のプロセスが設定されている。
|
||||
定義ファイル: [processes.json](./site-335411_②問い合わせ内容管理 Ver0.3/processes.json)(ドラフト版: [processes_質疑応答ドラフト.json](./site-335411_②問い合わせ内容管理 Ver0.3/processes_質疑応答ドラフト.json))
|
||||
|
||||
Status(メインのステータス)のラベル対応: 100新規/200登録済/300積算依頼/400受付済/450依頼者質疑中/500積算対応中/510外部見積待ち/520社内確認中/800積算回答/900完了/901依頼不要/910保留/920取消
|
||||
|
||||
| Id | Name | 実行条件(CurrentStatus→ChangedStatus) | 追加条件(View) | 権限 | OnClick |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| 1 | 質疑送信 | 510(外部見積待ち)→450(依頼者質疑中) | Statusが400/450/500/510/520のいずれかの間のみ表示 | Depts:[25](営業積算課), Groups:[19] | `$p.ex.processScript($(this));` |
|
||||
| 2 | 回答送信 | 450(依頼者質疑中)→450(変化なし) | ClassC(依頼者)が自分自身の場合のみ表示(`"Own"`) | 制限なし(View条件で依頼者本人に限定) | `$p.ex.processScript($(this));` |
|
||||
| 3 | 回答確認 | 450(依頼者質疑中)→500(積算対応中) | なし | Depts:[25](営業積算課) | `$p.ex.processScript($(this));` |
|
||||
| 4 | 質疑回答削除 | 450(依頼者質疑中)→450(変化なし) | なし | Depts:[25](営業積算課) | `$p.ex.processScript($(this));` |
|
||||
|
||||
補足:
|
||||
- OnClickはいずれもクリックされたボタン要素自体(`$(this)`)を渡す形式。呼び出し先の`$p.ex.processScript`(03_質疑応答スクリプトに実装)は、procNum(数値)ではなくボタンの表示ラベル文字列で処理を振り分けている。これはProcessesのId構成や件数が変わっても影響を受けないようにするための実装判断。
|
||||
- Id1「質疑送信」はCurrentStatus単体では510のみだが、View.ColumnFilterHashでStatus 400/450/500/510/520の間は表示されるようになっており、実質的な運用可能範囲はView条件側が決めている。
|
||||
- Id2「回答送信」はDeptsによる部署制限ではなく、View.ColumnFilterHashの`"ClassC": ["Own"]`条件でサーバー側に「依頼者本人のみ実行可」を強制している(Pleasanter標準機能)。
|
||||
|
||||
|
||||
## 9. スタイル(Styles)
|
||||
|
||||
| Id | Title | ファイル | 備考 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 00_読み取り専用スタイル.css | [1_00_読み取り専用スタイル.css.css](./site-335411_②問い合わせ内容管理 Ver0.3/styles/1_00_読み取り専用スタイル.css.css) | |
|
||||
| 2 | 01_共通スタイル.css | [2_01_共通スタイル.css.css](./site-335411_②問い合わせ内容管理 Ver0.3/styles/2_01_共通スタイル.css.css) | |
|
||||
| 3 | 04_質疑回答テーブル.css | [3_04_質疑回答テーブル.css.css](./site-335411_②問い合わせ内容管理 Ver0.3/styles/3_04_質疑回答テーブル.css.css) | 質疑応答履歴テーブルの配色・罫線 |
|
||||
|
||||
|
||||
## 10. スクリプト(Scripts)/サーバースクリプト(ServerScripts)
|
||||
|
||||
### Scripts
|
||||
|
||||
| Id | Title | ファイル | 要約 |
|
||||
| --- | --- | --- | --- |
|
||||
| 1 | 01_基本スクリプト.js | [1_01_基本スクリプト.js.js](./site-335411_②問い合わせ内容管理 Ver0.3/scripts/1_01_基本スクリプト.js.js) | 画面初期化、アップ希望日と期限日のチェック、依頼内容マージ、遅延連絡・算出対応ログの保存など。新規作成時にServerScript「祝日リスト取得スクリプト」の結果(HolidaysList)を使って期限日を算出する |
|
||||
| 2 | 02_添付ファイルDL.js | [2_02_添付ファイルDL.js.js](./site-335411_②問い合わせ内容管理 Ver0.3/scripts/2_02_添付ファイルDL.js.js) | 添付ファイル欄のダウンロードチェックボックス生成 |
|
||||
| 3 | 03_質疑応答スクリプト.js | [3_03_質疑応答スクリプト_修正版.js.js](./site-335411_②問い合わせ内容管理 Ver0.3/scripts/3_03_質疑応答スクリプト_修正版.js.js) | 質疑送信・回答送信・回答確認・削除の各プロセス(8.参照)から呼ばれ、質疑回答データ(Description071)への保存、履歴テーブル表示、タブの色分け、質疑回答期日の自動計算を行う |
|
||||
|
||||
### ServerScripts
|
||||
|
||||
| Id | Title | ファイル | 実行タイミング | 要約 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| 1 | 祝日リスト取得スクリプト.js | [1_祝日リスト取得スクリプト.js](./site-335411_②問い合わせ内容管理 Ver0.3/serverscripts/1_祝日リスト取得スクリプト.js) | BeforeOpeningPage(画面表示前) | 祝日テーブル(SiteId 192267)から本日−30日〜+60日分の祝日を取得し、Hidden項目`HolidaysList`に格納する |
|
||||
|
||||
|
||||
## 11. その他設定
|
||||
|
||||
| 設定キー | 値 |
|
||||
| --- | --- |
|
||||
|
||||
| Version | 1.017 |
|
||||
|
||||
| ReferenceType | Results |
|
||||
|
||||
| FilterColumns | Status, Owner, Class003, Class004, Class011, Class062, Class064, Class101, Class102, Class103, DateA, Date081 |
|
||||
|
||||
| GeneralTabLabelText | 問い合わせ情報 |
|
||||
|
||||
| TabLatestId | 4 |
|
||||
|
||||
| Tabs | [object Object], [object Object], [object Object], [object Object] |
|
||||
|
||||
| SectionLatestId | 18 |
|
||||
|
||||
| LinkColumns | ClassA, ClassB, ClassC, Class062, DateA, Class091, Description061 |
|
||||
|
||||
| Htmls | [object Object] |
|
||||
|
||||
| AutoVerUpType | 2 |
|
||||
|
||||
| NoDisplayIfReadOnly | false |
|
||||
|
||||
| NotInheritPermissionsWhenCreatingSite | false |
|
||||
@ -0,0 +1,26 @@
|
||||
# 概要書:遅延理由テンプレ管理
|
||||
|
||||
- この資料は業務担当者向けに、システム上の構成をもとに「何を管理し、どのような流れで使うか」を整理したものです。
|
||||
|
||||
- 技術仕様の詳細は [./site-335450_遅延理由テンプレ管理_spec.md](./site-335450_遅延理由テンプレ管理_spec.md) を参照してください。
|
||||
|
||||
|
||||
## 1. これはどんな画面か
|
||||
|
||||
このサイトは「遅延理由テンプレ管理」を管理するための業務画面です。Resultsとして扱われ、登録・照会・更新の基本的な業務フローに沿って利用されます。
|
||||
|
||||
## 2. 主に入力・参照する情報
|
||||
|
||||
| 項目 | 用途 |
|
||||
| --- | --- |
|
||||
| 遅延理由 | |
|
||||
| 内容 | |
|
||||
|
||||
|
||||
## 5. 利用時のポイント
|
||||
|
||||
- まずは画面上の入力項目を確認して、必要な情報を登録します。
|
||||
|
||||
- 進捗や状態はステータス項目で管理し、必要に応じて他サイトの情報を参照します。
|
||||
|
||||
- 重要な更新は履歴や添付情報と合わせて確認し、引き継ぎや問い合わせに備えます。
|
||||
Binary file not shown.
@ -0,0 +1,72 @@
|
||||
# サイト仕様書:遅延理由テンプレ管理(SiteId: 335450)
|
||||
|
||||
|
||||
- 取得元データ: [site-335450_latest.json](./site-335450_latest.json)
|
||||
|
||||
- 取得日時: 2026-06-08T19:47:32
|
||||
|
||||
- 設定バージョン: 1.017
|
||||
|
||||
|
||||
## 1. サイト基本情報
|
||||
|
||||
| 項目 | 値 |
|
||||
| --- | --- |
|
||||
| TenantId | 1 |
|
||||
| Title | 遅延理由テンプレ管理 |
|
||||
| ReferenceType | Results |
|
||||
| ParentId | 74663 |
|
||||
| InheritPermission | 74663 |
|
||||
| Publish | false |
|
||||
| DisableCrossSearch | false |
|
||||
| Creator | 737 |
|
||||
| Updator | 737 |
|
||||
| CreatedTime | 2026-05-31T15:46:25 |
|
||||
| UpdatedTime | 2026-06-08T19:47:32 |
|
||||
| SiteSettings.Version | 1.017 |
|
||||
|
||||
|
||||
## 3. 画面構成
|
||||
|
||||
### 3.1 一覧画面(GridColumns)
|
||||
|
||||
| 順番 | 列名 | 表示ラベル |
|
||||
| --- | --- | --- |
|
||||
| 1 | | |
|
||||
| 2 | | |
|
||||
| 3 | | |
|
||||
| 4 | | |
|
||||
| 5 | | |
|
||||
| 6 | | |
|
||||
| 7 | | |
|
||||
| 8 | | |
|
||||
|
||||
|
||||
### 3.2 編集画面レイアウト(EditorColumnHash / Sections)
|
||||
|
||||
| タブ/キー | 項目一覧 |
|
||||
| --- | --- |
|
||||
| General | ResultId, Ver, Title, Status, NumA, ClassA, DescriptionA, Comments |
|
||||
|
||||
|
||||
## 4. 項目定義(Columns)
|
||||
|
||||
| 列名(内部) | 種別 | 表示ラベル | 説明/入力ガイド | 備考 |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| ClassA | 文字列 | 遅延理由 | | |
|
||||
| DescriptionA | 説明 | 内容 | | |
|
||||
| NumA | 数値 | ソート用 | | |
|
||||
|
||||
|
||||
## 11. その他設定
|
||||
|
||||
| 設定キー | 値 |
|
||||
| --- | --- |
|
||||
|
||||
| Version | 1.017 |
|
||||
|
||||
| ReferenceType | Results |
|
||||
|
||||
| NoDisplayIfReadOnly | false |
|
||||
|
||||
| NotInheritPermissionsWhenCreatingSite | false |
|
||||
195
ClaudePleasanter/営業積算システム/extract-site-config.js
Normal file
195
ClaudePleasanter/営業積算システム/extract-site-config.js
Normal file
@ -0,0 +1,195 @@
|
||||
/**
|
||||
* extract-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* 取得済みのサイト構成 (siteSettingJsons/site-{SiteId}_latest.json) から、
|
||||
* Scripts / Styles / ServerScripts / Processes / ガイドHTML を
|
||||
* 個別ファイルとして configs/site-{SiteId}/ 配下に書き出します。
|
||||
*
|
||||
* 使い方:
|
||||
* node extract-site-config.js
|
||||
* node extract-site-config.js ./siteSettingJsons/site-214812_latest.json
|
||||
*
|
||||
* 出力:
|
||||
* configs/site-{SiteId}/
|
||||
* manifest.json ← 抽出内容の一覧(build-desired-config.jsでの再構成に使用)
|
||||
* scripts/{Id}_{Title}.js
|
||||
* styles/{Id}_{Title}.css
|
||||
* serverscripts/{Id}_{Title}.js
|
||||
* html/{GuideName}.html ← GridGuide等、ガイドHTML(値が空でないもののみ)
|
||||
* processes.json ← プロセス設定(SiteSettings.Processes をそのまま保存)
|
||||
*
|
||||
* 注意:
|
||||
* ガイドHTML(GridGuide等)はSiteSettingsではなく、サイトデータ直下の項目です。
|
||||
* そのため反映時はサイト設定の部分更新(updatesitesettings)ではなく、
|
||||
* サイト全体更新(updatesite / Mode:"full")が必要になります。
|
||||
* Scripts/Styles/ServerScripts/Processesは部分更新(Mode:"partial")で反映できます。
|
||||
* 詳しくは build-desired-config.js の実行結果メッセージを参照してください。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function sanitize(name) {
|
||||
return (
|
||||
String(name || "untitled")
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.trim()
|
||||
.slice(0, 60) || "untitled"
|
||||
);
|
||||
}
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function extractList(items, outDir, ext, bodyKey = "Body") {
|
||||
const manifestEntries = [];
|
||||
if (!items || items.length === 0) return manifestEntries;
|
||||
ensureDir(outDir);
|
||||
for (const item of items) {
|
||||
const fileName = `${item.Id}_${sanitize(item.Title || item.Name)}${ext}`;
|
||||
fs.writeFileSync(path.join(outDir, fileName), item[bodyKey] || "", "utf-8");
|
||||
const { [bodyKey]: _omit, ...meta } = item;
|
||||
manifestEntries.push({ ...meta, File: fileName });
|
||||
}
|
||||
return manifestEntries;
|
||||
}
|
||||
|
||||
// config.json の SiteId(数値 / カンマ区切り文字列 / 配列)を
|
||||
// 個々のSiteId文字列の配列に正規化する(get-site-config.jsと同仕様)。
|
||||
function parseSiteIds(rawSiteId) {
|
||||
const list = Array.isArray(rawSiteId) ? rawSiteId : String(rawSiteId).split(",");
|
||||
return list
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => id.length > 0);
|
||||
}
|
||||
|
||||
const GUIDE_FIELDS = [
|
||||
"GridGuide",
|
||||
"EditorGuide",
|
||||
"CalendarGuide",
|
||||
"CrosstabGuide",
|
||||
"GanttGuide",
|
||||
"BurnDownGuide",
|
||||
"TimeSeriesGuide",
|
||||
"AnalyGuide",
|
||||
"KambanGuide",
|
||||
"ImageLibGuide",
|
||||
];
|
||||
|
||||
// get-site-config.js からも呼び出せるよう関数化。
|
||||
// CLI実行時(直接 `node extract-site-config.js`)は末尾のブロックが引数解決を行う。
|
||||
function extractSiteConfig(latestPath) {
|
||||
const baseDir = __dirname;
|
||||
|
||||
if (!fs.existsSync(latestPath)) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${latestPath}`);
|
||||
console.error("先に node get-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const json = JSON.parse(fs.readFileSync(latestPath, "utf-8"));
|
||||
const data = json?.Response?.Data;
|
||||
if (!data) {
|
||||
console.error("[エラー] Response.Data が見つかりません。getsiteのレスポンス形式を確認してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteId = data.SiteId;
|
||||
const settings = data.SiteSettings || {};
|
||||
const dirName = `site-${siteId}_${sanitize(data.Title)}`;
|
||||
const outRoot = path.join(baseDir, "configs", dirName);
|
||||
ensureDir(outRoot);
|
||||
|
||||
const manifest = {
|
||||
SiteId: siteId,
|
||||
Title: data.Title,
|
||||
ExtractedAt: new Date().toISOString(),
|
||||
SourceFile: path.relative(baseDir, latestPath).replace(/\\/g, "/"),
|
||||
};
|
||||
|
||||
manifest.Scripts = extractList(settings.Scripts, path.join(outRoot, "scripts"), ".js");
|
||||
manifest.Styles = extractList(settings.Styles, path.join(outRoot, "styles"), ".css");
|
||||
manifest.ServerScripts = extractList(settings.ServerScripts, path.join(outRoot, "serverscripts"), ".js");
|
||||
|
||||
// ガイドHTML(サイトデータ直下。SiteSettingsではない点に注意)
|
||||
const htmlDir = path.join(outRoot, "html");
|
||||
const htmlEntries = [];
|
||||
for (const field of GUIDE_FIELDS) {
|
||||
const value = data[field];
|
||||
if (value) {
|
||||
ensureDir(htmlDir);
|
||||
const fileName = `${field}.html`;
|
||||
fs.writeFileSync(path.join(htmlDir, fileName), value, "utf-8");
|
||||
htmlEntries.push({ Field: field, File: fileName, TopLevel: true });
|
||||
}
|
||||
}
|
||||
manifest.Html = htmlEntries;
|
||||
|
||||
// プロセス設定 … 個別分割せず1つの定義ファイルとして保存
|
||||
fs.writeFileSync(
|
||||
path.join(outRoot, "processes.json"),
|
||||
JSON.stringify(settings.Processes || [], null, 2),
|
||||
"utf-8"
|
||||
);
|
||||
manifest.ProcessesFile = "processes.json";
|
||||
manifest.ProcessesCount = (settings.Processes || []).length;
|
||||
|
||||
fs.writeFileSync(path.join(outRoot, "manifest.json"), JSON.stringify(manifest, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 抽出完了: ${outRoot}`);
|
||||
console.log(` Scripts : ${manifest.Scripts.length}件`);
|
||||
console.log(` Styles : ${manifest.Styles.length}件`);
|
||||
console.log(` ServerScripts : ${manifest.ServerScripts.length}件`);
|
||||
console.log(` Html(ガイド) : ${manifest.Html.length}件`);
|
||||
console.log(` Processes : ${manifest.ProcessesCount}件 → processes.json`);
|
||||
console.log("\n次のステップ: 各ファイルを直接編集するか、Claudeに修正内容を指示してください。");
|
||||
console.log(`修正後は次のコマンドで反映用JSONを作成できます: node build-desired-config.js ${dirName}`);
|
||||
|
||||
return manifest;
|
||||
}
|
||||
|
||||
// CLIとして直接実行された場合のみ、引数からlatestPathを解決して実行する
|
||||
if (require.main === module) {
|
||||
const baseDir = __dirname;
|
||||
const latestPathArg = process.argv[2];
|
||||
|
||||
if (latestPathArg) {
|
||||
extractSiteConfig(latestPathArg);
|
||||
} else {
|
||||
const configPath = path.join(baseDir, "config.json");
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error("[エラー] config.jsonが見つかりません。抽出対象のJSONパスを引数で指定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const siteIds = parseSiteIds(config.SiteId);
|
||||
if (siteIds.length === 0) {
|
||||
console.error("[エラー] config.json の SiteId から有効なサイトIDを取得できませんでした。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const missing = [];
|
||||
for (const siteId of siteIds) {
|
||||
const latestPath = path.join(baseDir, "siteSettingJsons", `site-${siteId}_latest.json`);
|
||||
if (!fs.existsSync(latestPath)) {
|
||||
console.error(`[警告] ファイルが見つかりません(スキップ): ${latestPath}`);
|
||||
missing.push(siteId);
|
||||
continue;
|
||||
}
|
||||
if (siteIds.length > 1) {
|
||||
console.log(`\n==== SiteId: ${siteId} ====`);
|
||||
}
|
||||
extractSiteConfig(latestPath);
|
||||
}
|
||||
|
||||
if (missing.length > 0) {
|
||||
console.error(`\n[エラー] 未取得のSiteIdがあります: ${missing.join(", ")}`);
|
||||
console.error("先に node get-site-config.js を実行してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { extractSiteConfig };
|
||||
232
ClaudePleasanter/営業積算システム/generate-site-documentation-output.txt
Normal file
232
ClaudePleasanter/営業積算システム/generate-site-documentation-output.txt
Normal file
@ -0,0 +1,232 @@
|
||||
[INFO] 縺セ縺壽里蟄倥し繧、繝域ュ蝣ア繧貞叙蠕励・謚ス蜃コ縺励∪縺・..
|
||||
|
||||
==== [1/10] SiteId: 319014 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319014/getsite
|
||||
[INFO] SiteId: 319014
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319014_2026-07-10T09-04-47-964Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319014_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319014_譁ー繝サ遞溯ュー逕ウ隲・ Scripts : 0莉カ
|
||||
Styles : 0莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 0莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319014_譁ー繝サ遞溯ュー逕ウ隲・
|
||||
==== [2/10] SiteId: 319020 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319020/getsite
|
||||
[INFO] SiteId: 319020
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319020_2026-07-10T09-04-48-138Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319020_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319020_遞溯ュー鬆・岼
|
||||
Scripts : 0莉カ
|
||||
Styles : 0莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 0莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319020_遞溯ュー鬆・岼
|
||||
|
||||
==== [3/10] SiteId: 319021 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319021/getsite
|
||||
[INFO] SiteId: 319021
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319021_2026-07-10T09-04-48-248Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319021_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319021_遞溯ュー騾夂衍邂。逅・ Scripts : 0莉カ
|
||||
Styles : 0莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 0莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319021_遞溯ュー騾夂衍邂。逅・
|
||||
==== [4/10] SiteId: 319027 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319027/getsite
|
||||
[INFO] SiteId: 319027
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319027_2026-07-10T09-04-48-756Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319027_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319027_莠句燕遞溯ュー荳隕ァ
|
||||
Scripts : 2莉カ
|
||||
Styles : 2莉カ
|
||||
ServerScripts : 1莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319027_莠句燕遞溯ュー荳隕ァ
|
||||
|
||||
==== [5/10] SiteId: 335730 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/335730/getsite
|
||||
[INFO] SiteId: 335730
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-335730_2026-07-10T09-04-48-894Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-335730_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-335730_邨・ケ斐お繝ェ繧「繝槭せ繧ソ
|
||||
Scripts : 0莉カ
|
||||
Styles : 0莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 0莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-335730_邨・ケ斐お繝ェ繧「繝槭せ繧ソ
|
||||
|
||||
==== [6/10] SiteId: 319017 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319017/getsite
|
||||
[INFO] SiteId: 319017
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319017_2026-07-10T09-04-49-902Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319017_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319017_縲蝕SO縲鷹Κ鄂イ諡・ス楢・挨遞溯ュー髮・ィ・ Scripts : 0莉カ
|
||||
Styles : 1莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 0莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319017_縲蝕SO縲鷹Κ鄂イ諡・ス楢・挨遞溯ュー髮・ィ・
|
||||
==== [7/10] SiteId: 319015 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319015/getsite
|
||||
[INFO] SiteId: 319015
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319015_2026-07-10T09-04-51-222Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319015_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319015_譁ー繝サ遞溯ュー逕ウ隲区嶌
|
||||
Scripts : 2莉カ
|
||||
Styles : 6莉カ
|
||||
ServerScripts : 1莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 18莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319015_譁ー繝サ遞溯ュー逕ウ隲区嶌
|
||||
|
||||
==== [8/10] SiteId: 319022 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319022/getsite
|
||||
[INFO] SiteId: 319022
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319022_2026-07-10T09-04-52-277Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319022_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319022_縲宣Κ鄂イ蛻・縲醍ィ溯ュー謾ケ蝟・ Scripts : 1莉カ
|
||||
Styles : 3莉カ
|
||||
ServerScripts : 0莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 4莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319022_縲宣Κ鄂イ蛻・縲醍ィ溯ュー謾ケ蝟・
|
||||
==== [9/10] SiteId: 319016 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/319016/getsite
|
||||
[INFO] SiteId: 319016
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319016_2026-07-10T09-04-53-296Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319016_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319016_縲蝕SO縲醍ィ溯ュー邂。逅・ Scripts : 1莉カ
|
||||
Styles : 6莉カ
|
||||
ServerScripts : 1莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 5莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-319016_縲蝕SO縲醍ィ溯ュー邂。逅・
|
||||
==== [10/10] SiteId: 160926 ====
|
||||
[INFO] 蜿門セ怜・: https://nextoffice.next-hd.co.jp/pleasanter/api/items/160926/getsite
|
||||
[INFO] SiteId: 160926
|
||||
[INFO] 繝ャ繧ケ繝昴Φ繧ケ 繝医ャ繝励Ξ繝吶Ν繧ュ繝シ: [ 'StatusCode', 'Response' ]
|
||||
[INFO] Response驟堺ク九・繧ュ繝シ: [ 'Data' ]
|
||||
[OK] 菫晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-160926_2026-07-10T09-04-54-106Z.json
|
||||
[OK] 譛譁ー迚医→縺励※繧ゆソ晏ュ倥@縺セ縺励◆: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-160926_latest.json
|
||||
|
||||
[INFO] 邯壹¢縺ヲ險ュ螳壹・蛟句挨繝輔ぃ繧、繝ォ謚ス蜃コ繧貞ョ溯。後@縺セ縺・..
|
||||
[OK] 謚ス蜃コ螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-160926_遞溯ュー繧ウ繝シ繝臥ョ。逅・ Scripts : 3莉カ
|
||||
Styles : 3莉カ
|
||||
ServerScripts : 1莉カ
|
||||
Html(繧ャ繧、繝・ : 2莉カ
|
||||
Processes : 1莉カ 竊・processes.json
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ 蜷・ヵ繧。繧、繝ォ繧堤峩謗・邱ィ髮・☆繧九°縲,laude縺ォ菫ョ豁」蜀・ョケ繧呈欠遉コ縺励※縺上□縺輔>縲・菫ョ豁」蠕後・谺。縺ョ繧ウ繝槭Φ繝峨〒蜿肴丐逕ィJSON繧剃ス懈・縺ァ縺阪∪縺・ node build-desired-config.js site-160926_遞溯ュー繧ウ繝シ繝臥ョ。逅・
|
||||
==== 蜿門セ礼オ先棡縺セ縺ィ繧・====
|
||||
SiteId 319014: OK
|
||||
SiteId 319020: OK
|
||||
SiteId 319021: OK
|
||||
SiteId 319027: OK
|
||||
SiteId 335730: OK
|
||||
SiteId 319017: OK
|
||||
SiteId 319015: OK
|
||||
SiteId 319022: OK
|
||||
SiteId 319016: OK
|
||||
SiteId 160926: OK
|
||||
|
||||
谺。縺ョ繧ケ繝・ャ繝・ latest.json繧辰laude縺ョ繝√Ε繝・ヨ縺ォ繧「繝・・繝ュ繝シ繝峨@縺ヲ莉墓ァ俶嶌蛹悶r萓晞シ縺吶k縺九・configs/site-{SiteId}/ 驟堺ク九・繝輔ぃ繧、繝ォ繧堤峩謗・邱ィ髮・・謖・、コ縺励※菫ョ豁」縺励※縺上□縺輔>縲・
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319014 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319014_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319014_譁ー繝サ遞溯ュー逕ウ隲祇overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319014_譁ー繝サ遞溯ュー逕ウ隲祇overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319020 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319020_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319020_遞溯ュー鬆・岼_overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319020_遞溯ュー鬆・岼_overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319021 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319021_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319021_遞溯ュー騾夂衍邂。逅・overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319021_遞溯ュー騾夂衍邂。逅・overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319027 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319027_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319027_莠句燕遞溯ュー荳隕ァ_overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319027_莠句燕遞溯ュー荳隕ァ_overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=335730 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-335730_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-335730_邨・ケ斐お繝ェ繧「繝槭せ繧ソ_overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-335730_邨・ケ斐お繝ェ繧「繝槭せ繧ソ_overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319017 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319017_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319017_縲蝕SO縲鷹Κ鄂イ諡・ス楢・挨遞溯ュー髮・ィ・overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319017_縲蝕SO縲鷹Κ鄂イ諡・ス楢・挨遞溯ュー髮・ィ・overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319015 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319015_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319015_譁ー繝サ遞溯ュー逕ウ隲区嶌_overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319015_譁ー繝サ遞溯ュー逕ウ隲区嶌_overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319022 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319022_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319022_縲宣Κ鄂イ蛻・縲醍ィ溯ュー謾ケ蝟Юoverview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319022_縲宣Κ鄂イ蛻・縲醍ィ溯ュー謾ケ蝟Юoverview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=319016 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-319016_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319016_縲蝕SO縲醍ィ溯ュー邂。逅・overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-319016_縲蝕SO縲醍ィ溯ュー邂。逅・overview.pdf
|
||||
|
||||
==== 繝峨く繝・繝。繝ウ繝育函謌・ SiteId=160926 ====
|
||||
[OK] 莉墓ァ俶嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\configs\site-160926_spec.md
|
||||
[OK] 讎りヲ∵嶌逕滓・: C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-160926_遞溯ュー繧ウ繝シ繝臥ョ。逅・overview.md
|
||||
[OK] PDF逕滓・螳御コ・ C:\Users\k.nogi\#GitHub\ken_nogi\ClaudePleasanter\譁ー繝サ遞溯ュー逕ウ隲九す繧ケ繝・Β\docs\site-160926_遞溯ュー繧ウ繝シ繝臥ョ。逅・overview.pdf
|
||||
576
ClaudePleasanter/営業積算システム/generate-site-documentation.js
Normal file
576
ClaudePleasanter/営業積算システム/generate-site-documentation.js
Normal file
@ -0,0 +1,576 @@
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const child_process = require("child_process");
|
||||
|
||||
const ROOT = __dirname;
|
||||
const CONFIG_PATH = path.join(ROOT, "config.json");
|
||||
const CONFIGS_DIR = path.join(ROOT, "configs");
|
||||
const DOCS_DIR = path.join(ROOT, "docs");
|
||||
const SITESETTING_JSON_DIR = path.join(ROOT, "siteSettingJsons");
|
||||
|
||||
function ensureDir(dir) {
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
}
|
||||
|
||||
function readJson(filePath) {
|
||||
return JSON.parse(fs.readFileSync(filePath, "utf-8"));
|
||||
}
|
||||
|
||||
function sanitizeName(raw) {
|
||||
return String(raw || "").trim()
|
||||
.replace(/[\\/:*?"<>|]/g, "_")
|
||||
.replace(/\s+/g, " ")
|
||||
.replace(/\.+$/, "")
|
||||
.slice(0, 60)
|
||||
.trim();
|
||||
}
|
||||
|
||||
function parseSiteIds(rawSiteId) {
|
||||
const list = Array.isArray(rawSiteId) ? rawSiteId : String(rawSiteId).split(",");
|
||||
return list.map((id) => String(id).trim()).filter((id) => id.length > 0);
|
||||
}
|
||||
|
||||
function resolveSiteData(json) {
|
||||
if (json?.Response?.Data) return json.Response.Data;
|
||||
if (json?.Response?.Site?.Data) return json.Response.Site.Data;
|
||||
if (json?.Response?.Data?.SiteSettings) return json.Response.Data;
|
||||
if (json?.Response?.SiteSettings) return json.Response;
|
||||
throw new Error("getsiteレスポンスの想定構造が見つかりません。Response.Data を含むJSONを渡してください。");
|
||||
}
|
||||
|
||||
function resolveTableCellValue(row, header) {
|
||||
if (row[header] != null) return row[header];
|
||||
|
||||
const normalized = String(header)
|
||||
.replace(/\s+/g, "")
|
||||
.replace(/[()]/g, "")
|
||||
.replace(///g, "/");
|
||||
if (row[normalized] != null) return row[normalized];
|
||||
|
||||
const aliasMap = {
|
||||
"順番": ["No", "Order", "Index"],
|
||||
"列名": ["Column", "ColumnName", "Field", "Name"],
|
||||
"列名(内部)": ["Column", "ColumnName", "Field", "Name"],
|
||||
"表示ラベル": ["Label", "LabelText", "DisplayName", "Title"],
|
||||
"説明/入力ガイド": ["Description", "DescriptionText", "ToolTip", "InputHelpText"],
|
||||
"備考": ["Remarks", "Remark", "Notes"],
|
||||
"種別": ["Type", "Kind"],
|
||||
"対象": ["Target", "TargetId"],
|
||||
"権限値": ["Permission", "PermissionValue", "Level", "Value"],
|
||||
"タブ/キー": ["Tab", "Key", "TabKey"],
|
||||
"項目一覧": ["Items", "ItemList"],
|
||||
"項目": ["Field", "Column", "ColumnName"],
|
||||
"権限設定": ["Permission", "PermissionSetting", "Value"],
|
||||
"ファイル": ["File", "Path"],
|
||||
"要約": ["Summary", "Description"],
|
||||
"操作": ["Operation", "Name"],
|
||||
"状態の変化": ["Change", "Transition"],
|
||||
"説明": ["Description", "Detail"],
|
||||
"起点列": ["From", "FromColumn", "FromField"],
|
||||
"参照先SiteId": ["TargetSite", "TargetSiteId", "SiteId"],
|
||||
"参照先の値→コピー先": ["To", "ToColumn", "ToField"],
|
||||
"JsonFormat": ["JsonFormat", "Format"],
|
||||
"ラベル": ["Label", "LabelText"],
|
||||
"内容": ["Content", "Value", "Body"],
|
||||
};
|
||||
|
||||
for (const alias of aliasMap[header] || []) {
|
||||
if (row[alias] != null) return row[alias];
|
||||
}
|
||||
|
||||
return "";
|
||||
}
|
||||
|
||||
function formatMarkdownTable(header, rows) {
|
||||
if (!rows || rows.length === 0) return "";
|
||||
const heads = header.map((h) => `| ${h} `).join("") + "|";
|
||||
const sep = header.map(() => "| --- ").join("") + "|";
|
||||
const body = rows
|
||||
.map((row) => {
|
||||
return header
|
||||
.map((cell) => {
|
||||
const value = resolveTableCellValue(row, cell);
|
||||
return `| ${String(value).replace(/\|/g, "\\|")} `;
|
||||
})
|
||||
.join("") + "|";
|
||||
})
|
||||
.join("\n");
|
||||
return `${heads}\n${sep}\n${body}\n`;
|
||||
}
|
||||
|
||||
function inferColumnType(column) {
|
||||
const name = String(column.ColumnName || column.Name || "");
|
||||
if (/^(Class[A-Z]|Class\d{3}|Class)/.test(name)) return "文字列";
|
||||
if (/^Num/.test(name)) return "数値";
|
||||
if (/^Date/.test(name)) return "日付";
|
||||
if (/^Description/.test(name)) return "説明";
|
||||
if (/^Check/.test(name)) return "チェック";
|
||||
if (/^Attachments?/.test(name)) return "添付";
|
||||
if (/^(Owner|Manager|Assignee|CreatedBy|UpdatedBy|Updator|Users?)/.test(name)) return "ユーザー";
|
||||
if (/^Status$/i.test(name)) return "状態";
|
||||
if (/Id$/.test(name) && !/^Title$/i.test(name)) return "ID";
|
||||
if (/Title/i.test(name)) return "タイトル";
|
||||
return column.Type || column.FieldType || "不明";
|
||||
}
|
||||
|
||||
function getArray(value) {
|
||||
if (!value) return [];
|
||||
return Array.isArray(value) ? value : [value];
|
||||
}
|
||||
|
||||
function formatValue(value) {
|
||||
if (value === null || value === undefined) return "";
|
||||
if (typeof value === "boolean") return value ? "true" : "false";
|
||||
if (Array.isArray(value)) return value.join(", ");
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function findExtractionDir(siteId) {
|
||||
if (!fs.existsSync(CONFIGS_DIR)) return null;
|
||||
const candidates = fs.readdirSync(CONFIGS_DIR).filter((name) => {
|
||||
const full = path.join(CONFIGS_DIR, name);
|
||||
return fs.statSync(full).isDirectory() && name.startsWith(`site-${siteId}`);
|
||||
});
|
||||
return candidates[0] ? path.join(CONFIGS_DIR, candidates[0]) : null;
|
||||
}
|
||||
|
||||
function loadManifest(siteId) {
|
||||
const dir = findExtractionDir(siteId);
|
||||
if (!dir) return null;
|
||||
const manifestPath = path.join(dir, "manifest.json");
|
||||
if (!fs.existsSync(manifestPath)) return null;
|
||||
return readJson(manifestPath);
|
||||
}
|
||||
|
||||
function getSiteJsonPath(siteId) {
|
||||
const searchDirs = [SITESETTING_JSON_DIR, CONFIGS_DIR];
|
||||
for (const dir of searchDirs) {
|
||||
if (!fs.existsSync(dir)) continue;
|
||||
const candidate = path.join(dir, `site-${siteId}_latest.json`);
|
||||
if (fs.existsSync(candidate)) return candidate;
|
||||
const all = fs.readdirSync(dir).filter((name) => name.startsWith(`site-${siteId}_`) && name.endsWith(".json"));
|
||||
if (all.length > 0) return path.join(dir, all[0]);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildBasicInfoTable(data, settings) {
|
||||
const rows = [
|
||||
{ Key: "TenantId", Value: formatValue(data.TenantId) },
|
||||
{ Key: "Title", Value: formatValue(data.Title) },
|
||||
{ Key: "ReferenceType", Value: formatValue(data.ReferenceType) },
|
||||
{ Key: "ParentId", Value: formatValue(data.ParentId) },
|
||||
{ Key: "InheritPermission", Value: formatValue(data.InheritPermission) },
|
||||
{ Key: "Publish", Value: formatValue(data.Publish) },
|
||||
{ Key: "DisableCrossSearch", Value: formatValue(data.DisableCrossSearch) },
|
||||
{ Key: "Creator", Value: formatValue(data.Creator) },
|
||||
{ Key: "Updator", Value: formatValue(data.Updator || data.UpdatedBy) },
|
||||
{ Key: "CreatedTime", Value: formatValue(data.CreatedTime) },
|
||||
{ Key: "UpdatedTime", Value: formatValue(data.UpdatedTime) },
|
||||
{ Key: "SiteSettings.Version", Value: formatValue(settings?.Version) },
|
||||
];
|
||||
return rows.map((row) => `| ${row.Key} | ${row.Value} |`).join("\n") + "\n";
|
||||
}
|
||||
|
||||
function inferLinkDescription(link) {
|
||||
const from = link.From || link.FromColumn || link.FromField || "(不明)";
|
||||
const to = link.To || link.ToColumn || link.ToField || "(不明)";
|
||||
return `${from} → ${to}`;
|
||||
}
|
||||
|
||||
function scriptSummary(filePath) {
|
||||
if (!fs.existsSync(filePath)) return "";
|
||||
const content = fs.readFileSync(filePath, "utf-8");
|
||||
const line = content.split(/\r?\n/).find((l) => l.trim().length > 0);
|
||||
return line ? line.slice(0, 120) : "";
|
||||
}
|
||||
|
||||
function generateSpecMarkdown(siteId, data, settings, manifest) {
|
||||
const title = data.Title || `Site ${siteId}`;
|
||||
const specLines = [];
|
||||
specLines.push(`# サイト仕様書:${title}(SiteId: ${siteId})\n`);
|
||||
specLines.push(`- 取得元データ: [site-${siteId}_latest.json](./site-${siteId}_latest.json)`);
|
||||
specLines.push(`- 取得日時: ${formatValue(data.UpdatedTime || data.CreatedTime || "不明")}`);
|
||||
specLines.push(`- 設定バージョン: ${formatValue(settings?.Version || "不明")}\n`);
|
||||
|
||||
specLines.push(`## 1. サイト基本情報`);
|
||||
specLines.push("| 項目 | 値 |\n| --- | --- |\n" + buildBasicInfoTable(data, settings));
|
||||
|
||||
const permissions = settings?.Permissions || data.Permissions || [];
|
||||
if (permissions.length > 0) {
|
||||
specLines.push(`## 2. アクセス権限(Permissions)`);
|
||||
const permRows = permissions.map((perm) => ({
|
||||
Type: formatValue(perm.Type || perm.PermissionType || perm.AuthorityType),
|
||||
Target: formatValue(perm.TargetId || perm.Target || perm.RoleId || perm.Value),
|
||||
Level: formatValue(perm.Permission || perm.Value || perm.Level),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["種別", "対象", "権限値"], permRows));
|
||||
specLines.push(`※ 権限値は環境のロール定義に依存します。`);
|
||||
}
|
||||
|
||||
specLines.push(`## 3. 画面構成`);
|
||||
const gridColumns = settings?.GridColumns || [];
|
||||
if (gridColumns.length > 0) {
|
||||
specLines.push(`### 3.1 一覧画面(GridColumns)`);
|
||||
const gridRows = gridColumns.map((col, index) => ({
|
||||
No: index + 1,
|
||||
Column: formatValue(col.ColumnName || col.Column || col.Name || col.Field),
|
||||
Label: formatValue(col.LabelText || col.DisplayName || col.Title || ""),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["順番", "列名", "表示ラベル"], gridRows));
|
||||
}
|
||||
|
||||
const editorHash = settings?.EditorColumnHash || {};
|
||||
const sections = settings?.Sections || [];
|
||||
if (Object.keys(editorHash).length > 0 || sections.length > 0) {
|
||||
specLines.push(`### 3.2 編集画面レイアウト(EditorColumnHash / Sections)`);
|
||||
if (Object.keys(editorHash).length > 0) {
|
||||
const editorRows = Object.keys(editorHash).map((tabKey) => ({
|
||||
Tab: tabKey,
|
||||
Items: formatValue(getArray(editorHash[tabKey]).join(", ")),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["タブ/キー", "項目一覧"], editorRows));
|
||||
}
|
||||
if (sections.length > 0) {
|
||||
const sectionRows = sections.map((section) => ({
|
||||
Id: formatValue(section.Id),
|
||||
Label: formatValue(section.LabelText || section.Name || ""),
|
||||
AllowExpand: formatValue(section.AllowExpand),
|
||||
Expand: formatValue(section.Expand),
|
||||
}));
|
||||
specLines.push(`\n#### Sections`);
|
||||
specLines.push(formatMarkdownTable(["Id", "ラベル", "AllowExpand", "Expand"], sectionRows));
|
||||
}
|
||||
}
|
||||
|
||||
const titleColumns = settings?.TitleColumns || [];
|
||||
if (titleColumns.length > 0) {
|
||||
specLines.push(`### 3.3 タイトル表示(TitleColumns / TitleSeparator)`);
|
||||
specLines.push(`- タイトル項目: ${formatValue(titleColumns.join(" + "))}`);
|
||||
specLines.push(`- 区切り文字: ${formatValue(settings?.TitleSeparator || "")}`);
|
||||
}
|
||||
|
||||
const columns = settings?.Columns || [];
|
||||
if (columns.length > 0) {
|
||||
specLines.push(`## 4. 項目定義(Columns)`);
|
||||
const colRows = columns.map((col) => ({
|
||||
Column: formatValue(col.ColumnName || col.Name || col.Field || col.Id),
|
||||
Type: inferColumnType(col),
|
||||
Label: formatValue(col.LabelText || col.DisplayName || col.Title || ""),
|
||||
Description: formatValue(col.Description || col.ToolTip || col.InputHelpText || ""),
|
||||
Remarks: [col.NoWrap ? "NoWrap" : null, col.FieldCss ? "FieldCss" : null, col.ExtendedHtmlAfterControl ? "ExtendedHtmlAfterControl" : null].filter(Boolean).join(", "),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["列名(内部)", "種別", "表示ラベル", "説明/入力ガイド", "備考"], colRows));
|
||||
|
||||
const extendedItems = columns.filter((col) => col.ExtendedHtmlAfterControl);
|
||||
if (extendedItems.length > 0) {
|
||||
specLines.push(`## 4.1 項目ごとの補足HTML(ExtendedHtmlAfterControl)`);
|
||||
const extRows = extendedItems.map((col) => ({
|
||||
Column: formatValue(col.ColumnName || col.Name || col.Field),
|
||||
Label: formatValue(col.LabelText || col.DisplayName || col.Title || ""),
|
||||
Content: formatValue(col.ExtendedHtmlAfterControl),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["列名", "ラベル", "内容"], extRows));
|
||||
}
|
||||
}
|
||||
|
||||
const createPerm = settings?.PermissionForCreating || [];
|
||||
const updatePerm = settings?.PermissionForUpdating || [];
|
||||
if (createPerm.length > 0 || updatePerm.length > 0) {
|
||||
specLines.push(`## 5. 作成・更新権限(フィールド単位)`);
|
||||
if (createPerm.length > 0) {
|
||||
specLines.push(`### PermissionForCreating`);
|
||||
const createRows = createPerm.map((item) => ({
|
||||
Field: formatValue(item.ColumnName || item.Field || item.Column),
|
||||
Permission: formatValue(item.Required || item.Permission || item.Value),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["項目", "権限設定"], createRows));
|
||||
}
|
||||
if (updatePerm.length > 0) {
|
||||
specLines.push(`### PermissionForUpdating`);
|
||||
const updateRows = updatePerm.map((item) => ({
|
||||
Field: formatValue(item.ColumnName || item.Field || item.Column),
|
||||
Permission: formatValue(item.Required || item.Permission || item.Value),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["項目", "権限設定"], updateRows));
|
||||
}
|
||||
}
|
||||
|
||||
const aggregations = settings?.Aggregations || [];
|
||||
if (aggregations.length > 0) {
|
||||
specLines.push(`## 6. 集計設定(Aggregations)`);
|
||||
const aggRows = aggregations.map((item) => ({
|
||||
Id: formatValue(item.Id),
|
||||
GroupBy: formatValue(item.GroupBy),
|
||||
Type: formatValue(item.Type),
|
||||
Target: formatValue(item.Target),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["Id", "GroupBy", "Type", "Target"], aggRows));
|
||||
}
|
||||
|
||||
const links = settings?.Links || [];
|
||||
if (links.length > 0) {
|
||||
specLines.push(`## 7. 他サイト連携(Links / ルックアップ)`);
|
||||
const linkRows = links.map((link) => ({
|
||||
From: formatValue(link.From || link.FromColumn || link.FromField),
|
||||
TargetSite: formatValue(link.SiteId || link.TargetSiteId || link.ReferenceSiteId),
|
||||
To: formatValue(link.To || link.ToColumn || link.ToField),
|
||||
JsonFormat: formatValue(link.JsonFormat || link.Format),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["起点列", "参照先SiteId", "参照先の値→コピー先", "JsonFormat"], linkRows));
|
||||
}
|
||||
|
||||
const processes = manifest ? readJson(path.join(findExtractionDir(siteId), "processes.json")) : settings?.Processes || [];
|
||||
if (processes.length > 0) {
|
||||
specLines.push(`## 8. プロセス設定(Processes)`);
|
||||
specLines.push(`- 定義ファイル: [configs/${path.relative(ROOT, path.join(findExtractionDir(siteId), "processes.json")).replace(/\\/g, "/")}]`);
|
||||
const procRows = processes.map((proc) => ({
|
||||
Id: formatValue(proc.Id),
|
||||
Name: formatValue(proc.Name),
|
||||
DisplayName: formatValue(proc.DisplayName || proc.Name),
|
||||
Condition: formatValue(`${proc.CurrentStatus || ""} → ${proc.ChangedStatus || ""}`),
|
||||
Action: formatValue(proc.OnClick || proc.Action || proc.Name),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["Id", "Name", "DisplayName", "実行条件(Current→Changed)", "動作"], procRows));
|
||||
}
|
||||
|
||||
if (manifest?.Styles?.length > 0) {
|
||||
specLines.push(`## 9. スタイル(Styles)`);
|
||||
const styleRows = manifest.Styles.map((style) => ({
|
||||
Id: formatValue(style.Id),
|
||||
Title: formatValue(style.Title),
|
||||
File: `[${style.File}](./${path.posix.join(path.basename(findExtractionDir(siteId)), "styles", style.File)})`,
|
||||
Remarks: formatValue(style.Disabled ? "Disabled" : ""),
|
||||
}));
|
||||
specLines.push(formatMarkdownTable(["Id", "Title", "ファイル", "備考"], styleRows));
|
||||
}
|
||||
|
||||
const scripts = manifest?.Scripts || [];
|
||||
const serverScripts = manifest?.ServerScripts || [];
|
||||
if (scripts.length > 0 || serverScripts.length > 0) {
|
||||
specLines.push(`## 10. スクリプト(Scripts)/サーバースクリプト(ServerScripts)`);
|
||||
if (scripts.length > 0) {
|
||||
const scriptRows = scripts.map((script) => ({
|
||||
Id: formatValue(script.Id),
|
||||
Title: formatValue(script.Title),
|
||||
File: `[${script.File}](./${path.posix.join(path.basename(findExtractionDir(siteId)), "scripts", script.File)})`,
|
||||
Summary: formatValue(scriptSummary(path.join(findExtractionDir(siteId), "scripts", script.File))),
|
||||
}));
|
||||
specLines.push(`### Scripts`);
|
||||
specLines.push(formatMarkdownTable(["Id", "Title", "ファイル", "要約"], scriptRows));
|
||||
}
|
||||
if (serverScripts.length > 0) {
|
||||
const serverRows = serverScripts.map((script) => ({
|
||||
Id: formatValue(script.Id),
|
||||
Title: formatValue(script.Title),
|
||||
File: `[${script.File}](./${path.posix.join(path.basename(findExtractionDir(siteId)), "serverscripts", script.File)})`,
|
||||
Summary: formatValue(scriptSummary(path.join(findExtractionDir(siteId), "serverscripts", script.File))),
|
||||
}));
|
||||
specLines.push(`### ServerScripts`);
|
||||
specLines.push(formatMarkdownTable(["Id", "Title", "ファイル", "要約"], serverRows));
|
||||
}
|
||||
}
|
||||
|
||||
const otherSettings = { ...settings };
|
||||
["Permissions", "GridColumns", "EditorColumnHash", "Sections", "TitleColumns", "TitleSeparator", "Columns", "PermissionForCreating", "PermissionForUpdating", "Aggregations", "Links", "Processes", "Scripts", "Styles", "ServerScripts"].forEach((k) => delete otherSettings[k]);
|
||||
const otherEntries = Object.entries(otherSettings).filter(([key, value]) => value !== null && value !== undefined && value !== "" && !(Array.isArray(value) && value.length === 0) && !(typeof value === "object" && Object.keys(value).length === 0));
|
||||
if (otherEntries.length > 0) {
|
||||
specLines.push(`## 11. その他設定`);
|
||||
specLines.push(`| 設定キー | 値 |\n| --- | --- |`);
|
||||
otherEntries.forEach(([key, value]) => {
|
||||
specLines.push(`| ${key} | ${formatValue(value)} |`);
|
||||
});
|
||||
}
|
||||
|
||||
const alertRows = [];
|
||||
if (settings?.Scripts?.some((script) => /API_KEY|SECRET|Bearer\s+|token|webhook|password/i.test(script.Body || ""))) {
|
||||
alertRows.push("抽出されたスクリプト内に API キーやシークレット、Webhook URL、Bearerトークンなどの機密情報が含まれている可能性があります。内容を目視で確認してください。");
|
||||
}
|
||||
if (alertRows.length > 0) {
|
||||
specLines.push(`## 気になる点(レビュー観点)`);
|
||||
alertRows.forEach((line) => specLines.push(`- ${line}`));
|
||||
}
|
||||
|
||||
return specLines.join("\n\n");
|
||||
}
|
||||
|
||||
function generateOverviewMarkdown(siteId, data, settings, manifest) {
|
||||
const title = data.Title || `Site ${siteId}`;
|
||||
const sanitizedTitle = sanitizeName(title);
|
||||
const overviewLines = [];
|
||||
const columns = getArray(settings?.Columns || []).filter((col) => col.LabelText || col.DisplayName || col.Title);
|
||||
const processes = manifest ? readJson(path.join(findExtractionDir(siteId), "processes.json")) : settings?.Processes || [];
|
||||
const links = getArray(settings?.Links || []);
|
||||
const guideText = [data.GridGuide, data.EditorGuide, data.CalendarGuide, data.GanttGuide, data.CrosstabGuide, data.TimeSeriesGuide, data.BurnDownGuide, data.AnalyGuide, data.KambanGuide].filter(Boolean).join("\n");
|
||||
|
||||
overviewLines.push(`# 概要書:${title}`);
|
||||
overviewLines.push(`- この資料は業務担当者向けに、システム上の構成をもとに「何を管理し、どのような流れで使うか」を整理したものです。`);
|
||||
overviewLines.push(`- 技術仕様の詳細は [./site-${siteId}_${sanitizedTitle}_spec.md](./site-${siteId}_${sanitizedTitle}_spec.md) を参照してください。\n`);
|
||||
|
||||
overviewLines.push(`## 1. これはどんな画面か`);
|
||||
overviewLines.push(`このサイトは「${title}」を管理するための業務画面です。${data.ReferenceType || "Results"}として扱われ、登録・照会・更新の基本的な業務フローに沿って利用されます。`);
|
||||
if (guideText) {
|
||||
overviewLines.push(`- 画面上の案内では、${guideText.replace(/\n/g, " ").slice(0, 240)}${guideText.length > 240 ? "..." : ""}`);
|
||||
}
|
||||
|
||||
if (columns.length > 0) {
|
||||
overviewLines.push(`## 2. 主に入力・参照する情報`);
|
||||
const importantFields = columns.filter((col) => {
|
||||
const name = String(col.ColumnName || col.Name || "");
|
||||
const label = String(col.LabelText || col.DisplayName || col.Title || "");
|
||||
return /Status|ClassA|ClassB|ClassC|ClassD|ClassE|ClassF|ClassG|ClassH|ClassI|ClassJ|ClassK|ClassM|ClassN|ClassO|ClassZ|Date|Description|Attachments|Title/i.test(name + label);
|
||||
});
|
||||
const sourceFields = importantFields.length > 0 ? importantFields : columns;
|
||||
const items = sourceFields.slice(0, 12).map((col) => ({
|
||||
Field: formatValue(col.LabelText || col.DisplayName || col.Title || col.ColumnName || col.Name),
|
||||
Purpose: inferOverviewPurpose(col),
|
||||
}));
|
||||
overviewLines.push(formatMarkdownTable(["項目", "用途"], items));
|
||||
}
|
||||
|
||||
if (processes.length > 0) {
|
||||
overviewLines.push(`## 3. 業務でよく行う操作`);
|
||||
const flowRows = processes.slice(0, 12).map((proc) => ({
|
||||
Operation: formatValue(proc.Name || proc.DisplayName || "プロセス"),
|
||||
Change: formatValue(proc.CurrentStatus || "") + (proc.ChangedStatus ? ` → ${proc.ChangedStatus}` : ""),
|
||||
Description: inferProcessMeaning(proc),
|
||||
}));
|
||||
overviewLines.push(formatMarkdownTable(["操作", "状態の変化", "説明"], flowRows));
|
||||
}
|
||||
|
||||
if (links.length > 0) {
|
||||
overviewLines.push(`## 4. 他の管理表とのつながり`);
|
||||
links.forEach((link) => {
|
||||
const targetSite = formatValue(link.SiteId || link.TargetSiteId || link.ReferenceSiteId || "関連サイト");
|
||||
const desc = inferLinkMeaning(link);
|
||||
overviewLines.push(`- ${targetSite} の情報を参照・反映する連携です。${desc}`);
|
||||
});
|
||||
}
|
||||
|
||||
overviewLines.push(`## 5. 利用時のポイント`);
|
||||
overviewLines.push(`- まずは画面上の入力項目を確認して、必要な情報を登録します。`);
|
||||
overviewLines.push(`- 進捗や状態はステータス項目で管理し、必要に応じて他サイトの情報を参照します。`);
|
||||
overviewLines.push(`- 重要な更新は履歴や添付情報と合わせて確認し、引き継ぎや問い合わせに備えます。`);
|
||||
|
||||
return { markdown: overviewLines.join("\n\n"), sanitizedTitle };
|
||||
}
|
||||
|
||||
function inferOverviewPurpose(column) {
|
||||
const name = String(column.ColumnName || column.Name || "");
|
||||
const label = String(column.LabelText || column.DisplayName || column.Title || "");
|
||||
const text = `${name}${label}`;
|
||||
if (/Status/i.test(text)) return "登録状況や進捗を管理する項目です。";
|
||||
if (/ClassA|管理番号|管理/i.test(text)) return "案件や物件を一意に識別するための項目です。";
|
||||
if (/ClassB|登録者|担当|担当者/i.test(text)) return "誰が登録・担当しているかを管理する項目です。";
|
||||
if (/Date|日時|日付/i.test(text)) return "予定や実施日などの時点を管理する項目です。";
|
||||
if (/Description|備考|内容|コメント/i.test(text)) return "補足情報や業務メモを入力する項目です。";
|
||||
if (/Attachments|添付/i.test(text)) return "関連書類や証跡を添付するための項目です。";
|
||||
if (/Title/i.test(text)) return "件名や対象名を入力する項目です。";
|
||||
if (/Class[0-9]{3}|Class[0-9]{2,3}/i.test(text)) return "業務上の分類コードを入力する項目です。";
|
||||
return "業務上の情報を入力・保持するための項目です。";
|
||||
}
|
||||
|
||||
function inferProcessMeaning(proc) {
|
||||
const name = String(proc.Name || proc.DisplayName || "");
|
||||
const current = String(proc.CurrentStatus || "");
|
||||
const changed = String(proc.ChangedStatus || "");
|
||||
const text = `${name}${current}${changed}`;
|
||||
if (/承認|Approve|approval/i.test(text)) return "承認・確認の流れを進める操作です。";
|
||||
if (/保留|Pending|hold/i.test(text)) return "一時停止や後続作業への待ち状態を作る操作です。";
|
||||
if (/取消|Cancel|cancel/i.test(text)) return "登録内容を取り消す操作です。";
|
||||
if (/終了|End|complete|完了/i.test(text)) return "業務を完了状態に遷移させる操作です。";
|
||||
if (/登録|Create|new/i.test(text)) return "新規登録の初期状態を作る操作です。";
|
||||
if (/チェック|Check/i.test(text)) return "入力内容や整合性を確認する操作です。";
|
||||
return "業務状態を遷移させるための操作です。";
|
||||
}
|
||||
|
||||
function inferLinkMeaning(link) {
|
||||
const from = String(link.From || link.FromColumn || link.FromField || "");
|
||||
const to = String(link.To || link.ToColumn || link.ToField || "");
|
||||
if (from && to) return `${from} と ${to} の対応を取り、関連情報として参照します。`;
|
||||
return "関連するマスタや別サイトの情報を参照する連携です。";
|
||||
}
|
||||
|
||||
function resolveRequestedSiteIds(config) {
|
||||
const cliEntries = process.argv.slice(2);
|
||||
if (cliEntries.length > 0) {
|
||||
return cliEntries.flatMap((entry) => parseSiteIds(entry));
|
||||
}
|
||||
return parseSiteIds(config.SiteId);
|
||||
}
|
||||
|
||||
function generateAllDocs() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) {
|
||||
console.error(`[エラー] 設定ファイルが見つかりません: ${CONFIG_PATH}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = readJson(CONFIG_PATH);
|
||||
const siteIds = resolveRequestedSiteIds(config);
|
||||
if (siteIds.length === 0) {
|
||||
console.error("[エラー] config.json の SiteId から有効なサイトIDを取得できませんでした。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
ensureDir(CONFIGS_DIR);
|
||||
ensureDir(DOCS_DIR);
|
||||
|
||||
console.log("[INFO] まず既存サイト情報を取得・抽出します...");
|
||||
try {
|
||||
child_process.execFileSync("node", [path.join(ROOT, "get-site-config.js")], {
|
||||
stdio: "inherit",
|
||||
cwd: ROOT,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[エラー] get-site-config.js の実行に失敗しました。取得処理を確認してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
for (const siteId of siteIds) {
|
||||
console.log(`\n==== ドキュメント生成: SiteId=${siteId} ==== `);
|
||||
const siteJsonPath = getSiteJsonPath(siteId);
|
||||
if (!siteJsonPath) {
|
||||
console.error(`[警告] site-${siteId}_latest.json が見つかりません。スキップします。`);
|
||||
continue;
|
||||
}
|
||||
const json = readJson(siteJsonPath);
|
||||
let data;
|
||||
try {
|
||||
data = resolveSiteData(json);
|
||||
} catch (err) {
|
||||
console.error(`[エラー] ${siteJsonPath} のデータ構造を解釈できませんでした: ${err.message}`);
|
||||
continue;
|
||||
}
|
||||
const settings = data.SiteSettings || {};
|
||||
const manifest = loadManifest(siteId);
|
||||
|
||||
const specMarkdown = generateSpecMarkdown(siteId, data, settings, manifest);
|
||||
const titleToken = sanitizeName(data.Title || `site-${siteId}`);
|
||||
const specPath = path.join(DOCS_DIR, `site-${siteId}_${titleToken}_spec.md`);
|
||||
const legacySpecPath = path.join(CONFIGS_DIR, `site-${siteId}_spec.md`);
|
||||
fs.writeFileSync(specPath, specMarkdown, "utf-8");
|
||||
fs.writeFileSync(legacySpecPath, specMarkdown, "utf-8");
|
||||
console.log(`[OK] 仕様書生成: ${specPath}`);
|
||||
|
||||
const { markdown: overviewMarkdown, sanitizedTitle } = generateOverviewMarkdown(siteId, data, settings, manifest);
|
||||
const overviewName = `site-${siteId}_${sanitizedTitle}_overview.md`;
|
||||
const overviewPath = path.join(DOCS_DIR, overviewName);
|
||||
fs.writeFileSync(overviewPath, overviewMarkdown, "utf-8");
|
||||
console.log(`[OK] 概要書生成: ${overviewPath}`);
|
||||
|
||||
try {
|
||||
child_process.execFileSync("node", [path.join(ROOT, "md-to-pdf.js"), overviewPath], {
|
||||
stdio: "inherit",
|
||||
cwd: ROOT,
|
||||
});
|
||||
console.log(`[OK] PDF生成完了: ${overviewPath.replace(/\.md$/i, ".pdf")}`);
|
||||
} catch (err) {
|
||||
console.error(`[エラー] md-to-pdf.js による PDF 生成に失敗しました: ${overviewPath}`);
|
||||
console.error(err.message || err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
generateAllDocs();
|
||||
}
|
||||
166
ClaudePleasanter/営業積算システム/get-site-config.js
Normal file
166
ClaudePleasanter/営業積算システム/get-site-config.js
Normal file
@ -0,0 +1,166 @@
|
||||
/**
|
||||
* get-site-config.js
|
||||
* ------------------------------------------------------------
|
||||
* プリザンターの指定サイトの構成情報(SiteSettings等)を取得し、
|
||||
* ./siteSettingJsons/ 配下にタイムスタンプ付きJSONとして保存します。
|
||||
*
|
||||
* 使い方:
|
||||
* node get-site-config.js
|
||||
* node get-site-config.js ./config.json ← 設定ファイルを明示指定する場合
|
||||
*
|
||||
* 事前準備:
|
||||
* 1. config.example.json を config.json にコピーし、
|
||||
* BaseUrl / SiteId / ApiKey を実環境の値に書き換えてください。
|
||||
* SiteId はカンマ区切りで複数指定可能です(例: "480111,438166,96235")。
|
||||
* 1件のみの場合は従来通り数値(例: 480111)でも構いません。
|
||||
* 2. Node.js 18以降(組み込みfetchを使用)
|
||||
*
|
||||
* 参照した公式マニュアル:
|
||||
* - 開発者向け機能:API:サイト操作:サイト更新
|
||||
* https://pleasanter.org/manual/api-site-update
|
||||
* - 開発者向け機能:API:サイト操作:サイト設定の更新(部分追加/更新/削除)
|
||||
* https://pleasanter.org/ja/manual/api-update-sitesettings
|
||||
*
|
||||
* 注意:
|
||||
* プリザンターのバージョンにより get のレスポンス構造
|
||||
* (SiteSettingsが Response.Site 配下にあるか等)が異なる場合があります。
|
||||
* 初回実行時はコンソールに出力されるレスポンスのトップレベルキーを確認し、
|
||||
* 想定通りの構造か確認してください。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { extractSiteConfig } = require("./extract-site-config");
|
||||
|
||||
// config.json の SiteId(数値 / カンマ区切り文字列 / 配列)を
|
||||
// 個々のSiteId文字列の配列に正規化する。
|
||||
function parseSiteIds(rawSiteId) {
|
||||
const list = Array.isArray(rawSiteId) ? rawSiteId : String(rawSiteId).split(",");
|
||||
return list
|
||||
.map((id) => String(id).trim())
|
||||
.filter((id) => id.length > 0);
|
||||
}
|
||||
|
||||
// 1サイト分の取得・保存・抽出処理
|
||||
async function fetchAndSaveSite(siteId, { BaseUrl, ApiKey, ApiVersion }) {
|
||||
const url = `${BaseUrl.replace(/\/+$/, "")}/api/items/${siteId}/getsite`;
|
||||
const body = {
|
||||
ApiVersion: ApiVersion || "1.1",
|
||||
ApiKey: ApiKey,
|
||||
};
|
||||
|
||||
console.log(`[INFO] 取得先: ${url}`);
|
||||
console.log(`[INFO] SiteId: ${siteId}`);
|
||||
|
||||
let response;
|
||||
try {
|
||||
response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[エラー] リクエスト送信に失敗しました:", err.message);
|
||||
console.error("BaseUrlの疎通・ネットワーク設定を確認してください。");
|
||||
return false;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`[エラー] HTTP ${response.status} ${response.statusText}`);
|
||||
console.error(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
let json;
|
||||
try {
|
||||
json = JSON.parse(text);
|
||||
} catch (err) {
|
||||
console.error("[エラー] レスポンスがJSONとして解析できませんでした。");
|
||||
console.error(text);
|
||||
return false;
|
||||
}
|
||||
|
||||
// レスポンス構造の確認用ログ(初回はここを見て構造を把握してください)
|
||||
console.log("[INFO] レスポンス トップレベルキー:", Object.keys(json));
|
||||
if (json.Response) {
|
||||
console.log("[INFO] Response配下のキー:", Object.keys(json.Response));
|
||||
}
|
||||
|
||||
// 保存
|
||||
const outDir = path.join(__dirname, "siteSettingJsons");
|
||||
if (!fs.existsSync(outDir)) fs.mkdirSync(outDir, { recursive: true });
|
||||
|
||||
const timestamp = new Date()
|
||||
.toISOString()
|
||||
.replace(/[:.]/g, "-");
|
||||
const outFile = path.join(outDir, `site-${siteId}_${timestamp}.json`);
|
||||
|
||||
fs.writeFileSync(outFile, JSON.stringify(json, null, 2), "utf-8");
|
||||
|
||||
// 常に「最新」ファイルも別名で保持(Claudeへのアップロード用に固定名があると便利)
|
||||
const latestFile = path.join(outDir, `site-${siteId}_latest.json`);
|
||||
fs.writeFileSync(latestFile, JSON.stringify(json, null, 2), "utf-8");
|
||||
|
||||
console.log(`[OK] 保存しました: ${outFile}`);
|
||||
console.log(`[OK] 最新版としても保存しました: ${latestFile}`);
|
||||
|
||||
// 取得と同時に Scripts/Styles/ServerScripts/Processes/ガイドHTML も抽出する
|
||||
console.log("\n[INFO] 続けて設定の個別ファイル抽出を実行します...");
|
||||
extractSiteConfig(latestFile);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const configPath = process.argv[2] || path.join(__dirname, "config.json");
|
||||
|
||||
if (!fs.existsSync(configPath)) {
|
||||
console.error(`[エラー] 設定ファイルが見つかりません: ${configPath}`);
|
||||
console.error("config.example.json をコピーして config.json を作成してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const config = JSON.parse(fs.readFileSync(configPath, "utf-8"));
|
||||
const { BaseUrl, SiteId, ApiKey, ApiVersion } = config;
|
||||
|
||||
if (!BaseUrl || !SiteId || !ApiKey) {
|
||||
console.error("[エラー] config.json に BaseUrl / SiteId / ApiKey を設定してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const siteIds = parseSiteIds(SiteId);
|
||||
if (siteIds.length === 0) {
|
||||
console.error("[エラー] config.json の SiteId から有効なサイトIDを取得できませんでした。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const multiple = siteIds.length > 1;
|
||||
const results = [];
|
||||
|
||||
for (const [index, siteId] of siteIds.entries()) {
|
||||
if (multiple) {
|
||||
console.log(`\n==== [${index + 1}/${siteIds.length}] SiteId: ${siteId} ====`);
|
||||
}
|
||||
const ok = await fetchAndSaveSite(siteId, { BaseUrl, ApiKey, ApiVersion });
|
||||
results.push({ siteId, ok });
|
||||
}
|
||||
|
||||
if (multiple) {
|
||||
console.log("\n==== 取得結果まとめ ====");
|
||||
for (const { siteId, ok } of results) {
|
||||
console.log(` SiteId ${siteId}: ${ok ? "OK" : "失敗"}`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log("\n次のステップ: latest.jsonをClaudeのチャットにアップロードして仕様書化を依頼するか、");
|
||||
console.log("configs/site-{SiteId}/ 配下のファイルを直接編集・指示して修正してください。");
|
||||
|
||||
if (results.some((r) => !r.ok)) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
233
ClaudePleasanter/営業積算システム/md-to-pdf.js
Normal file
233
ClaudePleasanter/営業積算システム/md-to-pdf.js
Normal file
@ -0,0 +1,233 @@
|
||||
/**
|
||||
* md-to-pdf.js
|
||||
* ------------------------------------------------------------
|
||||
* Markdownファイル(見出し/表/太字/リンク/箇条書き/水平線程度の書式)を
|
||||
* 同名の .pdf として書き出します。外部npmパッケージ非依存(プロジェクトに
|
||||
* package.jsonが無いことに合わせ、Node標準モジュールのみで完結させています)。
|
||||
* PDF化にはOS標準のMicrosoft Edge(無ければGoogle Chrome)のヘッドレス印刷機能を使用します。
|
||||
*
|
||||
* 使い方:
|
||||
* node md-to-pdf.js ./docs/site-475384_overview.md
|
||||
* node md-to-pdf.js ./docs/*.md (シェルのグロブ展開に依存。複数ファイル可)
|
||||
*
|
||||
* 出力:
|
||||
* 入力と同じフォルダに {同名}.pdf を生成(中間HTMLは生成後に削除)
|
||||
*
|
||||
* 注意:
|
||||
* フォルダ名に # を含むパス(例: #GitHub)は file:// URL上でフラグメント区切りと
|
||||
* 誤認識されるため、url.pathToFileURL() で必ずエンコードしてからブラウザに渡すこと。
|
||||
* ------------------------------------------------------------
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const os = require("os");
|
||||
const url = require("url");
|
||||
const { execFileSync } = require("child_process");
|
||||
|
||||
const CANDIDATE_BROWSERS = [
|
||||
"C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe",
|
||||
"C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe",
|
||||
"C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe",
|
||||
];
|
||||
|
||||
function findBrowser() {
|
||||
for (const p of CANDIDATE_BROWSERS) {
|
||||
if (fs.existsSync(p)) return p;
|
||||
}
|
||||
console.error("[エラー] Microsoft EdgeまたはGoogle Chromeが見つかりませんでした。");
|
||||
console.error("インストール済みのブラウザパスをCANDIDATE_BROWSERSに追加してください。");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// --- 最小限のMarkdown→HTML変換(見出し/表/太字/リンク/箇条書き/水平線/段落のみ対応) ---
|
||||
function escapeHtml(s) {
|
||||
return s
|
||||
.replace(/&/g, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">");
|
||||
}
|
||||
|
||||
function renderInline(text) {
|
||||
let t = escapeHtml(text);
|
||||
t = t.replace(/\*\*(.+?)\*\*/g, "<strong>$1</strong>");
|
||||
t = t.replace(/`([^`]+)`/g, "<code>$1</code>");
|
||||
t = t.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
return t;
|
||||
}
|
||||
|
||||
function isTableSeparator(line) {
|
||||
return /^\s*\|?\s*:?-+:?\s*(\|\s*:?-+:?\s*)*\|?\s*$/.test(line) && line.includes("-");
|
||||
}
|
||||
|
||||
function splitTableRow(line) {
|
||||
let cells = line.trim();
|
||||
if (cells.startsWith("|")) cells = cells.slice(1);
|
||||
if (cells.endsWith("|")) cells = cells.slice(0, -1);
|
||||
return cells.split("|").map((c) => c.trim());
|
||||
}
|
||||
|
||||
function markdownToHtml(mdText) {
|
||||
const lines = mdText.split(/\r?\n/);
|
||||
const html = [];
|
||||
let i = 0;
|
||||
let inList = false;
|
||||
|
||||
function closeList() {
|
||||
if (inList) {
|
||||
html.push("</ul>");
|
||||
inList = false;
|
||||
}
|
||||
}
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
if (/^\s*$/.test(line)) {
|
||||
closeList();
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const heading = line.match(/^(#{1,6})\s+(.*)$/);
|
||||
if (heading) {
|
||||
closeList();
|
||||
const level = heading[1].length;
|
||||
html.push(`<h${level}>${renderInline(heading[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/^\s*---+\s*$/.test(line)) {
|
||||
closeList();
|
||||
html.push("<hr>");
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// テーブル: 次行がセパレータ行であること
|
||||
if (line.includes("|") && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
|
||||
closeList();
|
||||
const headerCells = splitTableRow(line);
|
||||
html.push("<table><thead><tr>" + headerCells.map((c) => `<th>${renderInline(c)}</th>`).join("") + "</tr></thead><tbody>");
|
||||
i += 2;
|
||||
while (i < lines.length && lines[i].includes("|") && !/^\s*$/.test(lines[i])) {
|
||||
const rowCells = splitTableRow(lines[i]);
|
||||
html.push("<tr>" + rowCells.map((c) => `<td>${renderInline(c)}</td>`).join("") + "</tr>");
|
||||
i++;
|
||||
}
|
||||
html.push("</tbody></table>");
|
||||
continue;
|
||||
}
|
||||
|
||||
const listItem = line.match(/^\s*-\s+(.*)$/);
|
||||
if (listItem) {
|
||||
if (!inList) {
|
||||
html.push("<ul>");
|
||||
inList = true;
|
||||
}
|
||||
html.push(`<li>${renderInline(listItem[1])}</li>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
closeList();
|
||||
html.push(`<p>${renderInline(line)}</p>`);
|
||||
i++;
|
||||
}
|
||||
closeList();
|
||||
return html.join("\n");
|
||||
}
|
||||
|
||||
const CSS = `
|
||||
@page { size: A4; margin: 18mm 16mm; }
|
||||
body {
|
||||
font-family: "Yu Gothic", "Meiryo", "Hiragino Sans", sans-serif;
|
||||
font-size: 10.5pt;
|
||||
line-height: 1.7;
|
||||
color: #1a1a1a;
|
||||
}
|
||||
h1 { font-size: 18pt; border-bottom: 3px solid #2a5599; padding-bottom: 6px; margin-top: 0; }
|
||||
h2 { font-size: 14pt; border-bottom: 1px solid #ccc; padding-bottom: 4px; margin-top: 28px; color: #2a5599; }
|
||||
h3 { font-size: 12pt; margin-top: 20px; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 10px 0 16px; font-size: 9.5pt; }
|
||||
th, td { border: 1px solid #999; padding: 5px 8px; text-align: left; vertical-align: top; }
|
||||
th { background-color: #2a5599; color: #fff; }
|
||||
tr:nth-child(even) td { background-color: #f4f7fb; }
|
||||
ul, ol { margin: 6px 0; padding-left: 22px; }
|
||||
li { margin: 3px 0; }
|
||||
p { margin: 8px 0; }
|
||||
code { background: #eee; padding: 1px 4px; border-radius: 3px; }
|
||||
hr { border: none; border-top: 1px solid #ccc; margin: 20px 0; }
|
||||
a { color: #2a5599; }
|
||||
`;
|
||||
|
||||
function convertOne(mdPath, browser) {
|
||||
const srcPath = path.resolve(mdPath);
|
||||
if (!fs.existsSync(srcPath)) {
|
||||
console.error(`[エラー] ファイルが見つかりません: ${srcPath}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const mdText = fs.readFileSync(srcPath, "utf-8");
|
||||
const titleMatch = mdText.match(/^#\s+(.+)$/m);
|
||||
const title = titleMatch ? titleMatch[1] : path.basename(srcPath);
|
||||
const bodyHtml = markdownToHtml(mdText);
|
||||
|
||||
const htmlDoc = `<!doctype html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${escapeHtml(title)}</title>
|
||||
<style>${CSS}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
const tmpHtmlPath = path.join(os.tmpdir(), `md-to-pdf-${Date.now()}-${Math.random().toString(36).slice(2)}.html`);
|
||||
const pdfPath = srcPath.replace(/\.md$/i, ".pdf");
|
||||
|
||||
fs.writeFileSync(tmpHtmlPath, htmlDoc, "utf-8");
|
||||
|
||||
try {
|
||||
const fileUrl = url.pathToFileURL(tmpHtmlPath).href;
|
||||
execFileSync(browser, [
|
||||
"--headless",
|
||||
"--disable-gpu",
|
||||
"--no-sandbox",
|
||||
`--print-to-pdf=${pdfPath}`,
|
||||
"--print-to-pdf-no-header",
|
||||
"--no-pdf-header-footer",
|
||||
fileUrl,
|
||||
]);
|
||||
console.log(`[OK] PDF生成: ${pdfPath}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.error(`[エラー] PDF生成に失敗しました: ${srcPath}`);
|
||||
console.error(err.message);
|
||||
return false;
|
||||
} finally {
|
||||
fs.unlinkSync(tmpHtmlPath);
|
||||
}
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const targets = process.argv.slice(2);
|
||||
if (targets.length === 0) {
|
||||
console.error("[エラー] 変換対象のMarkdownファイルを引数で指定してください。");
|
||||
console.error("例: node md-to-pdf.js ./docs/site-475384_overview.md");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const browser = findBrowser();
|
||||
let ok = true;
|
||||
for (const target of targets) {
|
||||
ok = convertOne(target, browser) && ok;
|
||||
}
|
||||
if (!ok) process.exit(1);
|
||||
}
|
||||
|
||||
module.exports = { convertOne, markdownToHtml };
|
||||
@ -0,0 +1,45 @@
|
||||
# 概要書:稟議コード管理
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-160926_spec.md](../configs/site-160926_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「稟議コード管理」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | エリア | |
|
||||
| | 稟議分類 | |
|
||||
| | 稟議コード | |
|
||||
| | 申請者 | |
|
||||
| | 契約コード | |
|
||||
| | 派生コード | |
|
||||
| | 用件・物件名 | |
|
||||
| | 申請日 | |
|
||||
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
|
||||
| 操作 | 状態の変化 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| 取消 | 100 → 910 | 発行された稟議コードを取り消します |
|
||||
|
||||
|
||||
## 4. 通知される場面
|
||||
|
||||
- 不明 に へ通知: コメントが追記されました 内容をご確認ください
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,14 @@
|
||||
# 概要書:新・稟議申請
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319014_spec.md](../configs/site-319014_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「新・稟議申請」で、Sitesを管理・参照するための画面です。
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- このサイトは現時点では項目・プロセス・通知・他サイト連携の情報が限定的です。
|
||||
Binary file not shown.
@ -0,0 +1,141 @@
|
||||
# 概要書:新・稟議申請書
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319015_spec.md](../configs/site-319015_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「新・稟議申請書」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 稟議コード | |
|
||||
| | エリア | |
|
||||
| | 稟議分類 | |
|
||||
| | 申請部署 | |
|
||||
| | 申請者 | |
|
||||
| | 社員番号 | |
|
||||
| | 上長(一次承認者) | |
|
||||
| | 役員(二次承認者) | |
|
||||
| | 関係者 | |
|
||||
| | 該当物件有無 | |
|
||||
| | マスターシート | |
|
||||
| | 契約コード | |
|
||||
| | 派生コード | |
|
||||
| | 工事名 | |
|
||||
| | 主担当営業 | |
|
||||
| | 担当IC | |
|
||||
| | 担当設計 | |
|
||||
| | 担当監督 | |
|
||||
| | 所属長 | |
|
||||
| | 担当役員 | |
|
||||
| | 工務エリア担当 | |
|
||||
| | 稟議件名 | |
|
||||
| | 稟議内容 | |
|
||||
| | 補足資料 | |
|
||||
| | 原価 | |
|
||||
| | 危険負担費(元) | |
|
||||
| | 今回使用額 | |
|
||||
| | 危険負担費(残金) | |
|
||||
| | 支払先チェック | |
|
||||
| | 支払合計 | |
|
||||
| | 事故内容・原因 | |
|
||||
| | 損害状況 | |
|
||||
| | 再発防止策 | |
|
||||
| | 是正処置の効果測定 | |
|
||||
| | 補足 | |
|
||||
| | 事故補足資料 | |
|
||||
| | 事前稟議 | |
|
||||
| | ステータス | |
|
||||
| | 申請日 | |
|
||||
| | 一次承認者 | |
|
||||
| | 一次承認日 | |
|
||||
| | 二次承認者 | |
|
||||
| | 二次承認日 | |
|
||||
| | 役員 | |
|
||||
| | 経理担当 | |
|
||||
| | 工務担当 | |
|
||||
| | アフター担当 | |
|
||||
| | アシスタント | |
|
||||
| | ISO担当 | |
|
||||
| | 最終決裁者 | |
|
||||
| | 最終決裁日 | |
|
||||
| | 質疑応答 | |
|
||||
| | 差戻し者 | |
|
||||
| | 差戻し日 | |
|
||||
| | 科目 | |
|
||||
| | 摘要 | |
|
||||
| | 経理処理日 | |
|
||||
| | 備考 | |
|
||||
| | 稟議コードリンク | |
|
||||
| | 役員 | |
|
||||
| | 経理 | |
|
||||
| | 豊藏社長 | |
|
||||
| | 期限 | |
|
||||
| | 差戻し権限者 | |
|
||||
| | 実損額 | |
|
||||
| | 取消日 | |
|
||||
| | 保留日 | |
|
||||
| | 再開日 | |
|
||||
| | 却下処理者 | |
|
||||
| | 却下処理日 | |
|
||||
| | 一次承認コメント | |
|
||||
| | 二次承認コメント | |
|
||||
| | 決裁コメント | |
|
||||
| | 却下コメント | |
|
||||
| | 差戻しコメント | |
|
||||
| | 一次承認コメント履歴 | |
|
||||
| | 二次承認コメント履歴 | |
|
||||
| | 決裁コメント履歴 | |
|
||||
| | 却下コメント履歴 | |
|
||||
| | 差戻しコメント履歴 | |
|
||||
| | 次の処理者 | |
|
||||
| | 経理確認 | |
|
||||
| | 経理処理者 | |
|
||||
| | 販売/購入額 | |
|
||||
| | 保険担当 | |
|
||||
| | 消費税 | |
|
||||
| | 今回支払額 | |
|
||||
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
|
||||
| 操作 | 状態の変化 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| 稟議申請 | -1 → 150 | 稟議申請 |
|
||||
| 一次承認 | 150 → 200 | 一次承認 |
|
||||
| 二次承認A | 200 → 300 | 二次承認 |
|
||||
| 二次承認B | 200 → 900 | 二次承認(決裁) |
|
||||
| 最終決裁 | 300 → 900 | 最終決裁 |
|
||||
| 差戻し | -1 → 110 | 差戻し |
|
||||
| 再申請 | 110 → 150 | 再申請 |
|
||||
| 取消 | -1 → 930 | 取消 |
|
||||
| 却下 | -1 → 920 | 却下 |
|
||||
| 保留 | -1 → 910 | 保留 |
|
||||
| 再開 | 910 → 120 | 再開 |
|
||||
| 新規登録 | 100 → 101 | 新規登録 |
|
||||
| 経理処理 | 900 → 990 | 経理処理 |
|
||||
| 事故報告書チェック | -1 → -1 | 事故報告書チェック |
|
||||
| 最終決裁B | 200 → 900 | 二次(最終)決裁 |
|
||||
| ISO稟議修正 | 900 → 940 | ISO稟議修正 |
|
||||
| ISO稟議承認 | 940 → 900 | ISO稟議承認 |
|
||||
| 取消(システム・総務) | -1 → 930 | 取消(システム・総務) |
|
||||
|
||||
|
||||
## 4. 通知される場面
|
||||
|
||||
- 不明 に へ通知: 稟議申請にて質疑応答コメントが追加されました■[Title]
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,203 @@
|
||||
# 概要書:【ISO】稟議管理
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319016_spec.md](../configs/site-319016_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「【ISO】稟議管理」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 稟議コード | |
|
||||
| | エリア | |
|
||||
| | 稟議分類 | |
|
||||
| | 申請部署 | |
|
||||
| | 申請者 | |
|
||||
| | 社員番号 | |
|
||||
| | 上長(一次承認者) | |
|
||||
| | 役員(二次承認者) | |
|
||||
| | 関係者 | |
|
||||
| | 関連案件有無 | |
|
||||
| | マスターシート | |
|
||||
| | 契約コード | |
|
||||
| | 派生コード | |
|
||||
| | 工事名 | |
|
||||
| | 主担当営業 | |
|
||||
| | 担当IC | |
|
||||
| | 担当設計 | |
|
||||
| | 担当監督 | |
|
||||
| | 所属長 | |
|
||||
| | 担当役員 | |
|
||||
| | 工務エリア担当 | |
|
||||
| | 稟議件名 | |
|
||||
| | 稟議内容 | |
|
||||
| | 補足資料 | |
|
||||
| | 原価 | |
|
||||
| | 危険負担費(元) | |
|
||||
| | 今回使用額 | |
|
||||
| | 危険負担費(残金) | |
|
||||
| | 支払先チェック | |
|
||||
| | 支払合計 | |
|
||||
| | 事故内容・原因 | |
|
||||
| | 損害状況 | |
|
||||
| | 再発防止策 | |
|
||||
| | 是正処置の効果測定 | |
|
||||
| | 補足 | |
|
||||
| | 事故補足資料 | |
|
||||
| | 事前稟議 | |
|
||||
| | ステータス | |
|
||||
| | 申請日 | |
|
||||
| | 一次承認者 | |
|
||||
| | 一次承認日 | |
|
||||
| | 二次承認者 | |
|
||||
| | 二次承認日 | |
|
||||
| | 役員 | |
|
||||
| | 経理担当 | |
|
||||
| | 工務担当 | |
|
||||
| | アフター担当 | |
|
||||
| | アシスタント | |
|
||||
| | ISO担当 | |
|
||||
| | 最終決裁者 | |
|
||||
| | 最終決裁日 | |
|
||||
| | 差戻し者 | |
|
||||
| | 差戻し日 | |
|
||||
| | 科目 | |
|
||||
| | 摘要 | |
|
||||
| | 経理処理日 | |
|
||||
| | 備考 | |
|
||||
| | 稟議コードリンク | |
|
||||
| | 役員 | |
|
||||
| | 経理 | |
|
||||
| | 豊藏社長 | |
|
||||
| | 期限 | |
|
||||
| | 差戻し権限者 | |
|
||||
| | 実損額 | |
|
||||
| | 取消日 | |
|
||||
| | 保留日 | |
|
||||
| | 再開日 | |
|
||||
| | 却下処理者 | |
|
||||
| | 却下処理日 | |
|
||||
| | 一次承認コメント | |
|
||||
| | 二次承認コメント | |
|
||||
| | 決裁コメント | |
|
||||
| | 却下コメント | |
|
||||
| | 差戻しコメント | |
|
||||
| | 一次承認コメント履歴 | |
|
||||
| | 二次承認コメント履歴 | |
|
||||
| | 決裁コメント履歴 | |
|
||||
| | 却下コメント履歴 | |
|
||||
| | 差戻しコメント履歴 | |
|
||||
| | 次の処理者 | |
|
||||
| | 経理確認 | |
|
||||
| | 経理処理者 | |
|
||||
| | 計上年 | |
|
||||
| | 計上月 | |
|
||||
| | 計上年月 | |
|
||||
| | 稟議項目 | |
|
||||
| | 稟議の原因 | |
|
||||
| | 是正状況 | |
|
||||
| | 再発状況 | |
|
||||
| | 是正/効果検証 | |
|
||||
| | 稟議概要 | |
|
||||
| | 要因分析 | |
|
||||
| | 今後の対策 | |
|
||||
| | 図面・資料など | |
|
||||
| | ①申請部署 | |
|
||||
| | ②関連部署 | |
|
||||
| | ③関連部署 | |
|
||||
| | ④関連部署 | |
|
||||
| | ⑤関連部署 | |
|
||||
| | ⑥関連部署 | |
|
||||
| | ⑦関連部署 | |
|
||||
| | ⑧関連部署 | |
|
||||
| | ⑨関連部署 | |
|
||||
| | ⑩関連部署 | |
|
||||
| | ①申請者 | |
|
||||
| | ②関連社員 | |
|
||||
| | ③関連社員 | |
|
||||
| | ④関連社員 | |
|
||||
| | ⑤関連社員 | |
|
||||
| | ⑥関連社員 | |
|
||||
| | ⑦関連社員 | |
|
||||
| | ⑧関連社員 | |
|
||||
| | ⑨関連社員 | |
|
||||
| | ⑩関連社員 | |
|
||||
| | 割当金1 | |
|
||||
| | 割当金2 | |
|
||||
| | 全社員リスト5 | |
|
||||
| | 全社員リスト1 | |
|
||||
| | 全社員リスト2 | |
|
||||
| | 全社員リスト3 | |
|
||||
| | 全社員リスト4 | |
|
||||
| | 販売/購入額 | |
|
||||
| | 部門長共有 | |
|
||||
| | 部門長共有日 | |
|
||||
| | 全体会議共有 | |
|
||||
| | 全体会議共有日 | |
|
||||
| | 稟議概要件名 | |
|
||||
| | 消費税 | |
|
||||
| | 今回支払額 | |
|
||||
| | 全社員リスト6 | |
|
||||
| | 全社員リスト7 | |
|
||||
| | 全社員リスト8 | |
|
||||
| | 全社員リスト9 | |
|
||||
| | 全社員リスト10 | |
|
||||
| | 稟議該当者チェック | |
|
||||
| | ISO確認者 | |
|
||||
| | 役員確認者 | |
|
||||
| | ISO確認日 | |
|
||||
| | 役員確認日 | |
|
||||
| | 分析期限 | |
|
||||
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
|
||||
| 操作 | 状態の変化 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| ISO確認中 | 100 → 150 | ISO確認中 |
|
||||
| 役員依頼 | 150 → 170 | 役員依頼 |
|
||||
| 役員分析 | 170 → 200 | 役員分析 |
|
||||
| 保留 | -1 → 990 | 保留 |
|
||||
| 再計上 | -1 → -1 | 再計上 |
|
||||
|
||||
|
||||
## 4. 通知される場面
|
||||
|
||||
- 不明 に へ通知: 通知が送信されます。
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,40 @@
|
||||
# 概要書:【ISO】部署担当者別稟議集計
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319017_spec.md](../configs/site-319017_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「【ISO】部署担当者別稟議集計」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 稟議コード | |
|
||||
| | 該当部署 | |
|
||||
| | 該当社員 | |
|
||||
| | 計上年 | |
|
||||
| | 計上月 | |
|
||||
| | 【ISO】稟議管理 | |
|
||||
| | 稟議決裁額 | |
|
||||
| | 按分割当額 | |
|
||||
| | 稟議件名 | |
|
||||
| | 備考 | |
|
||||
| | 計上年月 | |
|
||||
| | 稟議項目 | |
|
||||
| | 稟議の原因 | |
|
||||
| | エリア | |
|
||||
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,16 @@
|
||||
# 概要書:稟議項目
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319020_spec.md](../configs/site-319020_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「稟議項目」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 稟議項目 | |
|
||||
Binary file not shown.
@ -0,0 +1,22 @@
|
||||
# 概要書:稟議通知管理
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319021_spec.md](../configs/site-319021_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「稟議通知管理」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | エリア | |
|
||||
| | 役員 | |
|
||||
| | 経理担当 | |
|
||||
| | 工務担当 | |
|
||||
| | アフター担当 | |
|
||||
| | アシスタント | |
|
||||
| | ISO担当 | |
|
||||
Binary file not shown.
@ -0,0 +1,43 @@
|
||||
# 概要書:【部署別】稟議改善
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319022_spec.md](../configs/site-319022_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「【部署別】稟議改善」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 部署 | |
|
||||
| | 取組み内容 | |
|
||||
| | 稟議 | |
|
||||
| | 取組み | |
|
||||
| | 参加者 | |
|
||||
| | 参考資料 | |
|
||||
| | 確認者 | |
|
||||
| | 確認日 | |
|
||||
| | 担当役員 | |
|
||||
| | 全体共有日 | |
|
||||
| | 議事録の要約 | |
|
||||
|
||||
|
||||
## 3. 業務の流れ(ボタン操作)
|
||||
|
||||
| 操作 | 状態の変化 | 説明 |
|
||||
| --- | --- | --- |
|
||||
| 登録完了 | 100 → 150 | 登録完了 |
|
||||
| 確認完了 | 150 → 800 | 確認完了 |
|
||||
| 全体共有 | 800 → 900 | 全体共有 |
|
||||
| 差戻し | -1 → 100 | 差戻し |
|
||||
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,34 @@
|
||||
# 概要書:事前稟議一覧
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-319027_spec.md](../configs/site-319027_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「事前稟議一覧」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | エリア | |
|
||||
| | 稟議分類 | |
|
||||
| | 申請者 | |
|
||||
| | 契約コード | |
|
||||
| | 派生コード | |
|
||||
| | 用件・物件名 | |
|
||||
| | 申請日 | |
|
||||
| | 稟議コード | |
|
||||
|
||||
|
||||
## 5. 関連する仕組み(マスタ連携)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
|
||||
- 関連マスタ連携: (不明) → (不明)
|
||||
Binary file not shown.
@ -0,0 +1,19 @@
|
||||
# 概要書:組織エリアマスタ
|
||||
|
||||
- 技術仕様(項目のシステム上の名称・設定値等)は [../configs/site-335730_spec.md](../configs/site-335730_spec.md) を参照してください。
|
||||
|
||||
- 本書は業務担当者向けに機能面のみを平易に説明するものです。
|
||||
|
||||
|
||||
## 1. これは何か
|
||||
|
||||
このサイトは「組織エリアマスタ」で、Resultsを管理・参照するための画面です。
|
||||
|
||||
## 2. 登場する情報(入力項目)
|
||||
|
||||
| 分類 | 項目 | 内容 |
|
||||
| --- | --- | --- |
|
||||
| | 組織ID | |
|
||||
| | 組織コード | |
|
||||
| | 組織名 | |
|
||||
| | エリア | |
|
||||
Binary file not shown.
BIN
PleasanterSystem/dbbackup.zip
Normal file
BIN
PleasanterSystem/dbbackup.zip
Normal file
Binary file not shown.
7
PleasanterSystem/dbbackup/.gitignore
vendored
Normal file
7
PleasanterSystem/dbbackup/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# 認証情報・秘密鍵(絶対にコミットしない)
|
||||
.env
|
||||
private_*.key
|
||||
|
||||
# ローカル一時ファイル
|
||||
/logs/
|
||||
/dump_tmp/
|
||||
67
PleasanterSystem/dbbackup/HANDOFF.md
Normal file
67
PleasanterSystem/dbbackup/HANDOFF.md
Normal file
@ -0,0 +1,67 @@
|
||||
# 引き継ぎメモ (サーバー設置作業用)
|
||||
|
||||
このメモは、開発機(Windows)でのやり取りを踏まえて、サーバー上のClaudeに引き継ぐための現状整理です。設計の詳細は `SPEC.md` を参照してください。ここでは **「今どこまで終わっていて、次に何をすべきか」** に絞って書きます。
|
||||
|
||||
## 現状ステータス(結論から)
|
||||
|
||||
- コードは一式実装済み・構文チェック済み(Windows開発機で `node --check` 全ファイルOK)。
|
||||
- **未完了・最大のブロッカー**: LINE WORKSの共有ドライブAPIは **Service Account認証(JWT)では利用できない**ことが判明した(公式ドキュメント明記 + 実機検証で確認)。そのため、共有ドライブ操作(backup.jsのアップロード/フォルダ作成/削除/一覧)には **User Account認証(OAuth 2.0 認可コードフロー)** が必須で、これは**人間が一度だけブラウザでログイン・同意する手順**が必要。この手順がまだ実施されていない(`.env` の `LW_USER_REFRESH_TOKEN` が空)。
|
||||
- pg_dump / createdb / dropdb は開発機(Windows)には存在しないため未検証。実サーバー(Linux、PostgreSQL稼働環境)で初めて実地検証できる。
|
||||
- コード一式はまだgitにコミットされていない(ローカルファイルのみ)。`.env` と秘密鍵ファイルは `.gitignore` 済みなのでgit経由では転送されない → **サーバーへは別途安全な方法で転送する必要がある**。
|
||||
|
||||
## サーバーでまずやること(優先順)
|
||||
|
||||
1. **リポジトリ配置**: `dbbackup/` フォルダ一式をサーバーに配置(gitで転送する場合、`.env` と `private_*.key` は `.gitignore` 対象のため別送が必要)。
|
||||
2. **Node.jsバージョン確認**: `node -v` が `18.13.0` 以上であること(`fs.openAsBlob` を使用しているため)。
|
||||
3. **`.env` の内容確認・上書き**:
|
||||
- PostgreSQL接続系(`PG_DUMP_BIN`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`)をサーバーの実環境に合わせて確認・修正。特に `PG_DUMP_BIN=/usr/bin/pg_dump` のパスが実際に存在するか確認。
|
||||
- `BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/` が書き込み可能なディレクトリか確認。
|
||||
- LINE WORKS認証情報は現在下記の**新しいService Account**が設定済み(旧アカウントから切り替え済み):
|
||||
- `LW_CLIENT_ID=LFE8mn9WwDXOfESHLGha`
|
||||
- `LW_SERVICE_ACCOUNT=dxkz7.serviceaccount@next-hd.co.jp`
|
||||
- `LW_PRIVATE_KEY_FILE=./private_20260711105832.key`(このファイルもサーバーへ配置必須)
|
||||
4. **User Account認証のセットアップ(最重要・未完了)**:
|
||||
- Developer Consoleの「User Account認証」欄のRedirect URLに、`.env` の `LW_REDIRECT_URI`(現状 `https://example.com/callback`)と**完全一致**する値を登録する。サーバー環境でこの値を変える場合は `.env` も同時に書き換えること。
|
||||
- サーバー上(またはブラウザ操作可能な端末)で `node lineworks-authorize.js` を実行し、対話プロンプトの指示に従って:
|
||||
1. 表示された認可URLをブラウザで開く
|
||||
2. `kenichiro.nogi@next-hd.co.jp` でログイン・同意(**パスワードはブラウザのLINE WORKSログイン画面のみに入力し、スクリプトやAIには絶対に渡さない**)
|
||||
3. リダイレクト後のURL(または `code=` の値)をターミナルに貼り付け
|
||||
- 成功すると `refresh_token` が `.env` の `LW_USER_REFRESH_TOKEN` に自動保存される。Refresh Token Rotationが有効なため、以後 `backup.js` 実行のたびに新しい `refresh_token` が自動で書き戻される(手動操作は初回のみでよい)。
|
||||
5. **動作確認**:
|
||||
```
|
||||
node backup.js create-test-db
|
||||
node backup.js run --database=pleasanter_backup_test
|
||||
node backup.js drop-test-db
|
||||
```
|
||||
`SPEC.md` の「動作確認方法」セクションに詳細な確認観点あり(共有ドライブへのフォルダ作成、ログアップロード、`.env`への`LW_SHAREDRIVE_ID`書き込み等)。
|
||||
6. 問題なければ、`.env` の `PGDATABASE` を実データベース名(既定 `Implem.Pleasanter`)に戻して本番実行、その後cron等への登録を検討。
|
||||
|
||||
## これまでの重要な調査結果(繰り返し確認しないための記録)
|
||||
|
||||
- **「Not allowed api」エラーの原因はスコープ不足ではない**。`file`/`file.read`スコープを正しく要求してもService Account認証では共有ドライブAPIが403になる。スコープが本当に不足している場合は別のエラーメッセージ(`has not permission api scope`)が返ることを、`board`スコープで意図的に間違えた検証で確認済み。
|
||||
- 新しいService Accountを発行して再検証しても同じ結果(403 `Not allowed api`)だった → アカウント固有の設定漏れではなく、**Service Account認証という方式自体の制約**と判断。
|
||||
- 根拠ドキュメント: https://developers.worksmobile.com/jp/docs/drive に「Drive API は、User Account 認証で取得した Access Token で利用できます。Service Account 認証 (JWT) で取得した Token では利用できません。」と明記(マイドライブ・共有ドライブ・組織/グループフォルダ全てに適用される全体制約として記載)。
|
||||
- 掲示板(board)APIは引き続きService Account認証で問題なく動作する(`get-token.js` で確認済み)。今回の制約はDrive/共有ドライブ系APIのみ。
|
||||
|
||||
## ファイル構成の要点
|
||||
|
||||
| ファイル | 役割 |
|
||||
|---|---|
|
||||
| `backup.js` | メインのバックアップ実行エントリポイント。`run`/`create-test-db`/`drop-test-db` サブコマンド |
|
||||
| `lineworks-authorize.js` | User Account認証の**一回限り**のセットアップ(対話式、人間の操作が必須) |
|
||||
| `get-token.js` | Service Account認証の動作確認用CLI(掲示板等、Drive以外の用途) |
|
||||
| `lib/lineworksUserAuth.js` | backup.jsが使うUser Account認証(OAuth)。refresh_token管理 |
|
||||
| `lib/lineworksAuth.js` | get-token.jsが使うService Account認証(JWT) |
|
||||
| `lib/lineworksDrive.js` | 共有ドライブAPI呼び出しラッパー(フォルダ作成・アップロード・削除・一覧) |
|
||||
| `lib/pgDump.js` | pg_dump実行・分割書き出し |
|
||||
| `lib/env.js` | `.env`読み込み(常にファイルが正)・書き戻し |
|
||||
| `lib/logger.js` | 月次ローテーションするログ(INFO/WARN/ERROR) |
|
||||
| `lib/testDb.js` | テスト用DB作成・削除 |
|
||||
| `SPEC.md` | 詳細設計書(全体像・APIエンドポイント一覧・処理フロー) |
|
||||
| `sftp.sh` | **旧手段。使用しない・参照しない**(ユーザー指定) |
|
||||
|
||||
## 未解決・要判断事項
|
||||
|
||||
- `LW_REDIRECT_URI` を `https://example.com/callback`(プレースホルダー)のまま使うか、実際に管理下のURLに変更するか未確定。プレースホルダーのままでも動作上は問題ない(ブラウザのアドレスバーからcodeを手動コピーする運用のため)が、気になる場合はDeveloper Console側の登録値と `.env` を揃えて変更可能。
|
||||
- cron等への自動実行登録はまだ未着手(今回のスコープ外、動作確認後の話)。
|
||||
- `BACKUP_RETENTION_COUNT`(既定100)や `PG_DUMP_SPLIT_SIZE_MB`(既定3072)は初期値のままなので、必要に応じてサーバー側で調整。
|
||||
192
PleasanterSystem/dbbackup/SPEC.md
Normal file
192
PleasanterSystem/dbbackup/SPEC.md
Normal file
@ -0,0 +1,192 @@
|
||||
# PostgreSQL → LINE WORKS 共有ドライブ 自動バックアップ (Node.js)
|
||||
|
||||
## Context
|
||||
|
||||
現行の `backup.sh` は `pg_dump` でローカルにダンプを作成するところまでは動いているが、転送処理(`sftp.sh` 経由)はコメントアウトされて使われていない。転送先を独自VPN上のSFTPサーバーから **LINE WORKS ドライブの共有ドライブ**に変更したいというのが今回の要望。
|
||||
|
||||
**認証方式について**: 当初 `get-token.js` / 姉妹プロジェクトの `lineworks-anythingllm.js`(LINE WORKS 掲示板連携)と同じ「Service AccountのJWT自己署名 → OAuth2トークン取得」を共有ドライブAPIにも流用する想定だったが、実装・検証の結果、**Drive/共有ドライブAPIはService Account認証(JWT)に対応しておらず、User Account認証(OAuth 2.0 認可コードフロー)が必須**であることが判明した(公式ドキュメント `https://developers.worksmobile.com/jp/docs/drive` に明記、かつ実機検証でも「スコープ不足」とは異なるエラー`Not allowed api`を確認)。そのため:
|
||||
- 掲示板API等、既存のService Account認証で動くもの → 従来通り `get-token.js` / `lib/lineworksAuth.js`
|
||||
- Drive/共有ドライブAPI(backup.jsが使うもの) → 新設の `lib/lineworksUserAuth.js`(User Account認証、OAuth認可コードフロー)
|
||||
|
||||
を使い分ける。User Account認証は実ユーザー(kenichiro.nogi@next-hd.co.jp)による一度きりのブラウザ認可が必要なため、`lineworks-authorize.js` という一回限りのセットアップスクリプトを用意し、そこで取得した `refresh_token` を `.env` に保存、以降 `backup.js` は無人で `refresh_token` から `access_token` を再発行する。
|
||||
|
||||
保存先は 共有ドライブ「システムバックアップ」 > `Pleasanter_DB_Backup` > 日付フォルダ(`YYYYMMDD`) で、フォルダが無ければ都度作成。ファイル名側に秒単位まで含むタイムスタンプを付与し、同日複数回実行しても上書きされないようにする。アップロード後は日付フォルダの個数を数え、保持回数(既定100・`.env`で変更可)を超えた古いフォルダをLINE WORKS側から削除してローテーションする。
|
||||
|
||||
さらに、バックアップ開始・終了、アップロード開始・終了、LINE WORKSログイン(トークン取得)からディレクトリ取得・作成までの各過程を秒単位のタイムスタンプ付きで詳細ログに記録する。ログは同じ共有ドライブの `Pleasanter_DB_Backup/logs/` 配下に月1回ローテーション(月ごとに1ファイル)で保存し、実行のたびに上書きアップロードする。通常の経過はINFO、リトライ等の異常系はWARN/ERRORで記録する。
|
||||
|
||||
`sftp.sh` は「以前の手段」なので使用しない・参照もしない。
|
||||
|
||||
任意にパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を試せるよう、本番DBに触れずに使えるテスト用DBの作成・削除コマンドも `backup.js` のサブコマンドとして用意する。
|
||||
|
||||
## 参照した公式ドキュメント(エンドポイント確定分)
|
||||
|
||||
| 操作 | Method | URL |
|
||||
|---|---|---|
|
||||
| 共有ドライブ一覧 | GET | `/v1.0/sharedrives` → `{ sharedrives: [{ sharedriveId, name, ... }] }` |
|
||||
| ルート直下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files` → `{ files: [...], responseMetaData: { nextCursor } }` |
|
||||
| フォルダ配下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/children` (同形式) |
|
||||
| ルート直下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/createfolder` body `{ fileName }` → 201 `{ fileId, fileType:"FOLDER", ... }` |
|
||||
| フォルダ配下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/createfolder` body `{ fileName }` |
|
||||
| アップロードURL発行(段階1) | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` body `{ fileName, fileSize, modifiedTime, overwrite, resume, suffixOnDuplicate }` → `{ uploadUrl, offset }` |
|
||||
| 実バイト送信(段階2) | POST | `uploadUrl` (別ホスト `apis-storage.worksmobile.com`) — `multipart/form-data`, フィールド名 `Filedata`, `Authorization: Bearer` 同梱 → 201 `{ fileId, fileName, fileSize }` |
|
||||
| 削除(ファイル/フォルダ) | DELETE | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` → 204 |
|
||||
|
||||
必要スコープ: `file`(読み書き全般)。`.env` の `LW_SCOPE` は `get-token.js`(Service Account認証)専用に残し、バックアップ用(User Account認証)には別変数 `LW_SCOPE_DRIVE` を新設して混同しないようにする。
|
||||
|
||||
### 認可コードフロー / リフレッシュ関連エンドポイント
|
||||
|
||||
| 操作 | Method | URL |
|
||||
|---|---|---|
|
||||
| 認可リクエスト | GET(ブラウザ) | `https://auth.worksmobile.com/oauth2/v2.0/authorize?client_id=...&redirect_uri=...&response_type=code&scope=file&state=...` |
|
||||
| 認可コード→トークン交換 | POST | `https://auth.worksmobile.com/oauth2/v2.0/token` (`grant_type=authorization_code`) |
|
||||
| リフレッシュ | POST | 同上 (`grant_type=refresh_token`) |
|
||||
|
||||
Refresh Token Rotation が ON のため、リフレッシュのたびに新しい `refresh_token` が発行され古い方は無効化される。取得の都度 `.env` の `LW_USER_REFRESH_TOKEN` を書き換えて保存する。
|
||||
|
||||
## 新規ファイル構成 (dbbackup/ 配下)
|
||||
|
||||
```
|
||||
dbbackup/
|
||||
backup.js … エントリポイント (node backup.js)
|
||||
lineworks-authorize.js … User Account認証の一回限りのセットアップ(ブラウザ認可→refresh_token取得・.env保存)
|
||||
lib/
|
||||
env.js … .env の読み込み(process.envへ反映。.envが常に正、実行環境変数より優先)・LW_SHAREDRIVE_ID/LW_USER_REFRESH_TOKEN書き戻し
|
||||
logger.js … 月次ローテーションするローカルログファイルへの書き込み(INFO/WARN/ERROR、時分秒付き)
|
||||
lineworksAuth.js … get-token.js が使うService Account認証(JWT)。コード内にデフォルト値は持たず、.env未設定なら例外
|
||||
lineworksUserAuth.js … backup.jsが使うUser Account認証(OAuth認可コードフロー)。認可URL組立て・コード交換・refresh_tokenによる再発行(rotation対応)
|
||||
lineworksDrive.js … 共有ドライブAPI一覧のラッパー(共有ドライブID解決・フォルダ確保・アップロード・一覧・削除)、429リトライ付きfetch。logger連携で各API呼び出しをINFO/WARNログに記録
|
||||
pgDump.js … pg_dump をspawnし、標準出力を3072MB単位でローカルファイルに分割書き出し
|
||||
testDb.js … createdb/dropdb をspawnしてテスト用DBを作成・削除するヘルパー
|
||||
logs/ … ログのローカル置き場(月次ファイル、既定 backup-YYYYMM.log)
|
||||
package.json … 依存ゼロ(fetch/FormData/Blob/fs.openAsBlob等 Node標準のみ)。engines: node>=18.13
|
||||
get-token.js … Service Account認証の動作確認用CLI。内部実装は lib/lineworksAuth.js を呼ぶ薄いラッパー。コード内にデフォルト値を持たず、必要な環境変数が.envに無ければ実行不可
|
||||
.env … 下記キーを追記
|
||||
SPEC.md … 本仕様書(このplanの内容)をそのまま保存。実装の最初のステップとしてコミットする
|
||||
```
|
||||
|
||||
`sftp.sh` は変更・参照しない。
|
||||
|
||||
### `.env` 追記内容
|
||||
|
||||
```
|
||||
# --- Drive(共有ドライブ)バックアップ関連 (User Account認証) ---
|
||||
LW_SCOPE_DRIVE=file
|
||||
LW_REDIRECT_URI=https://example.com/callback
|
||||
LW_USER_REFRESH_TOKEN=
|
||||
LW_SHAREDRIVE_NAME=システムバックアップ
|
||||
LW_SHAREDRIVE_ID=
|
||||
LW_BACKUP_ROOT_FOLDER_NAME=Pleasanter_DB_Backup
|
||||
|
||||
# --- PostgreSQL接続 (backup.sh のデフォルトを踏襲) ---
|
||||
PG_DUMP_BIN=/usr/bin/pg_dump
|
||||
PGHOST=localhost
|
||||
PGPORT=5432
|
||||
PGUSER=postgres
|
||||
PGPASSWORD=
|
||||
PGDATABASE=Implem.Pleasanter
|
||||
|
||||
# --- ローカル一時ファイル・分割・ローテーション ---
|
||||
BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/
|
||||
PG_DUMP_SPLIT_SIZE_MB=3072
|
||||
BACKUP_RETENTION_COUNT=100
|
||||
|
||||
# --- ログ ---
|
||||
LOG_LOCAL_DIR=./logs
|
||||
LW_LOG_FOLDER_NAME=logs
|
||||
|
||||
# --- テスト用DB(create-test-db / drop-test-db) ---
|
||||
PG_CREATEDB_BIN=createdb
|
||||
PG_DROPDB_BIN=dropdb
|
||||
TEST_DB_NAME=pleasanter_backup_test
|
||||
```
|
||||
|
||||
`LW_SHAREDRIVE_ID` は初回実行時に名前(`LW_SHAREDRIVE_NAME`)から解決し、`lib/env.js` が `.env` ファイルの当該行を書き換えて永続化する。2回目以降はID解決APIを呼ばずキャッシュ値を使う。
|
||||
|
||||
## 処理フロー (`backup.js`)
|
||||
|
||||
全体を `try { ... } catch { ...ERRORログ... } finally { ...ログファイルのアップロード... }` で包む。`accessToken` / `sharedriveId` / `backupFolderId` / `logsFolderId` は関数スコープの変数に保持し、`finally` 内でログアップロード時に(まだ未取得なら)再利用・再取得する。
|
||||
|
||||
1. `.env` 読み込み(`lib/env.js`、`process.env` に無いキーだけ反映=実環境変数を優先)。`logger.info("===== バックアップ開始 =====")`。
|
||||
2. `lib/pgDump.js`: `logger.info("pg_dump開始: host=... db=...")` → `PG_DUMP_BIN -h $PGHOST -p $PGPORT -U $PGUSER -Fc $PGDATABASE` を spawn(`PGPASSWORD` 設定時のみ子プロセスのenvに渡す)。標準出力を `BACKUP_LOCAL_DIR` 配下に `postgres-<YYYYMMDD-HHMMSS>.dmp000`, `.dmp001`, … として `PG_DUMP_SPLIT_SIZE_MB` ごとに書き出す(`split -d -a 3` と同じ3桁ゼロ埋め連番)。完了時 `logger.info("pg_dump終了: 合計サイズ=... 所要時間=...")`。exit code非0またはstderrで失敗時は `logger.error(...)` を出して例外を投げここで中断(アップロード・ローテーションは行わない)。
|
||||
3. `logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)")` → `lib/lineworksUserAuth.js` の `getUserAccessToken()` で `LW_USER_REFRESH_TOKEN` から `access_token` を再発行(rotationで返る新しい`refresh_token`は即座に`.env`へ保存) → 成功で `logger.info("アクセストークン取得成功")`、失敗で `logger.error(...)`(`LW_USER_REFRESH_TOKEN`未設定時は「先に`lineworks-authorize.js`を実行してください」という内容の例外)。
|
||||
4. `lib/lineworksDrive.js`(各呼び出し前後でINFOログ、429リトライはWARNログを`logger`経由で出力):
|
||||
- `resolveSharedDriveId()`: `.env` の `LW_SHAREDRIVE_ID` があれば `logger.info("共有ドライブID: キャッシュ値を使用 (...)")`。無ければ一覧取得→`LW_SHAREDRIVE_NAME`と一致するものを検索→`.env`へ書き戻し、`logger.info("共有ドライブID解決: name=... id=...")`。見つからなければ`logger.error`してエラー終了。
|
||||
- `ensureFolder(parentFileId, name)`: 一覧を見て同名の`FOLDER`があれば `logger.info("フォルダ確認: 既存を使用 name=... id=...")`、無ければ作成し `logger.info("フォルダ確認: 新規作成 name=... id=...")`。これを `Pleasanter_DB_Backup`(ルート直下)→ 日付フォルダ`YYYYMMDD`(その配下)の2階層分呼ぶ。
|
||||
- 分割されたダンプファイルを順番に `uploadFile(sharedriveId, dateFolderId, filePath)` でアップロード。各ファイルについて `logger.info("アップロード開始: ファイル名=... サイズ=...")` → 段階1でuploadUrl取得→段階2でPOST → 成功で `logger.info("アップロード成功: ファイル名=... fileId=... 所要時間=...")`、失敗で `logger.error(...)` して例外を投げ中断(ローカルファイルは削除しない=再実行できるように残す)。大きいファイルは `fs.openAsBlob()` でメモリに全展開せず送信。
|
||||
5. 全パートのアップロードに成功したら `BACKUP_LOCAL_DIR` 上の当該一時ファイルを削除し `logger.info("ローカル一時ファイル削除完了")`。
|
||||
6. ローテーション: `logger.info("ローテーション開始 (保持件数=...)")` → `Pleasanter_DB_Backup` 配下のフォルダ一覧を取得し、フォルダ名(=日付文字列なので辞書順=時系列順)でソート。`BACKUP_RETENTION_COUNT` を超えた古い方から `deleteFile(sharedriveId, fileId)` で削除しつつ `logger.info("削除: フォルダ名=... fileId=...")`。`logger.info("ローテーション終了: 削除件数=...")`。
|
||||
7. `logger.info("===== バックアップ終了(成功) ===== 所要時間=...")`。失敗時は catch 節で `logger.error("===== バックアップ終了(失敗): ...")` を出し `process.exitCode = 1`。
|
||||
8. `finally` 節: `logger.info("ログファイルアップロード開始")` → 4.で解決済みの `sharedriveId` を使って(未解決なら改めて解決を試み、それも失敗したらログアップロードは諦めローカルログのみ残す)`Pleasanter_DB_Backup/logs` フォルダを確保し、当月ログファイル(`backup-YYYYMM.log`)を `overwrite: true` でアップロード(段階1リクエストの`overwrite`フラグをtrueにして同名ファイルを上書き)。成功/失敗いずれも最後にコンソールへも出力。
|
||||
|
||||
## 初回セットアップ (`lineworks-authorize.js`)
|
||||
|
||||
Drive API利用に必須のUser Account認証を一度だけ行うスクリプト。
|
||||
|
||||
1. 事前にDeveloper Consoleの「User Account認証」欄のRedirect URLに `.env` の `LW_REDIRECT_URI` と完全一致する値を登録しておく。
|
||||
2. `node lineworks-authorize.js` を実行すると認可URLが表示されるので、ブラウザで開き対象アカウント(例: kenichiro.nogi@next-hd.co.jp)でログイン・同意する。**パスワードなど認証情報はこちらのスクリプトや対話には一切入力しない**(ブラウザ上のLINE WORKSログイン画面でのみ入力する)。
|
||||
3. 同意後リダイレクトされた画面のURL(またはcodeパラメータの値)をコンソールに貼り付ける。
|
||||
4. `code`を`access_token`/`refresh_token`に交換し、`refresh_token`を`.env`の`LW_USER_REFRESH_TOKEN`に保存する。
|
||||
5. 以降 `backup.js` は無人でこの`refresh_token`から`access_token`を再発行する。Refresh Token Rotationにより毎回新しい`refresh_token`が発行されるため、`lib/lineworksUserAuth.js`の`getUserAccessToken()`は取得の都度`.env`へ書き戻す。
|
||||
|
||||
## CLIサブコマンド (`backup.js`)
|
||||
|
||||
`process.argv[2]` をコマンド名として単純にディスパッチする(追加npm依存なし)。
|
||||
|
||||
| コマンド | 内容 |
|
||||
|---|---|
|
||||
| `node backup.js` または `node backup.js run [--database=<dbName>]` | 通常のバックアップフロー(上記1〜8)を実行。`--database`指定時は同プロセス内で`PGDATABASE`をその値に上書きしてから実行(`.env`は書き換えない=使い捨ての一時上書き)。 |
|
||||
| `node backup.js create-test-db [dbName]` | テスト用DBを作成(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
|
||||
| `node backup.js drop-test-db [dbName]` | テスト用DBを削除(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
|
||||
|
||||
これにより、本番DBに触れず以下のようなセルフテストが可能になる:
|
||||
```
|
||||
node backup.js create-test-db
|
||||
node backup.js run --database=pleasanter_backup_test
|
||||
node backup.js drop-test-db
|
||||
```
|
||||
|
||||
### `lib/testDb.js`
|
||||
|
||||
- `createTestDb(dbName)`: `PG_CREATEDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER <dbName>` を spawn(`PGPASSWORD`設定時は子プロセスのenvへ渡す)。既に存在する場合はcreatedbのエラーをそのまま表示して終了(先に`drop-test-db`を促すメッセージを添える)。
|
||||
- `dropTestDb(dbName)`: `PG_DROPDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER --if-exists <dbName>` を spawn。`--if-exists`により未作成でもエラーにしない。
|
||||
- どちらも接続先ホスト/ポート/ユーザーは通常バックアップと同じ`.env`の`PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`を再利用する(接続先サーバーは同じ、対象DB名だけが違う)。
|
||||
- 実行結果は`console.log`/`console.error`のみ(通常バックアップの月次ログ・LINE WORKSへのログアップロードとは無関係な開発用ユーティリティのため、`logger`/Driveアップロードには連携しない)。
|
||||
|
||||
## ログ設計 (`lib/logger.js`)
|
||||
|
||||
- 出力先はローカルの `LOG_LOCAL_DIR/backup-YYYYMM.log`(月が変わったら自動的に別ファイルに切り替わる=月1回ローテーション)。プロセス内でファイルハンドルを使い回さず、書き込みの都度対象月のパスを計算して追記(`fs.appendFileSync`)することで日付またぎにも自然に対応する。
|
||||
- 1行フォーマット: `YYYY-MM-DD HH:mm:ss [LEVEL] message`(時分秒まで必須)。
|
||||
- `logger.info(msg)` / `logger.warn(msg)` / `logger.error(msg)` の3レベル。すべて `console.log`/`console.warn`/`console.error` にも同時出力し、標準出力からも経過が追えるようにする。
|
||||
- 記録対象(すべて時分秒付きINFO、異常系はWARN/ERROR):
|
||||
- バックアップ処理全体の開始・終了(成功/失敗、所要時間)
|
||||
- pg_dumpの開始・終了(失敗時はERRORでstderr内容も記録)
|
||||
- LINE WORKSアクセストークン取得(scope、開始・成功・失敗)
|
||||
- 共有ドライブID解決(キャッシュ使用/新規検索の別)
|
||||
- `Pleasanter_DB_Backup`・日付フォルダ・`logs`フォルダそれぞれの取得/作成(既存流用か新規作成か、fileId)
|
||||
- 各ダンプファイルのアップロード開始・成功・失敗
|
||||
- 429レートリミット時のリトライ(WARN、待機秒数)
|
||||
- ローカル一時ファイル削除
|
||||
- ローテーションで削除したフォルダ一覧
|
||||
- ログファイル自体のアップロード開始・成功・失敗
|
||||
- `lib/lineworksDrive.js` の各関数は `logger` を受け取って(または直接requireして)上記のログを出す。`lwFetch`内の429リトライ箇所も `console.warn` ではなく `logger.warn` に統一する。
|
||||
|
||||
## 再利用・共通化する既存ロジック
|
||||
|
||||
- JWT組立て・トークン取得: `get-token.js` / `lineworks-anythingllm.js` の `createJwtAssertion` / `getAccessToken` をほぼそのまま `lib/lineworksAuth.js` に切り出す(scopeを引数化するだけで、`get-token.js`の board向けデフォルトはCLI側に残す)。
|
||||
- 429リトライ付きfetch: `lineworks-anythingllm.js` の `lwFetch`(スロットリング+Retry-After対応)と同じ実装を `lib/lineworksDrive.js` 内に置く(ロジックはコピーだが依存関係を増やしたくないため別ファイル化はしない)。
|
||||
- ファイル名の日時フォーマットは `backup.sh` の `date '+%Y%m%d-%H%M%S'` 相当をJSで再現。
|
||||
|
||||
## 動作確認方法
|
||||
|
||||
1. `node dbbackup/get-token.js` でService Account認証のトークン取得自体が引き続き動くことを確認(リファクタ後の非破壊確認。`.env`に`LW_CLIENT_ID`等が無ければ実行不可になることも確認)。
|
||||
2. `node dbbackup/lineworks-authorize.js` を一度実行し、User Account認証(kenichiro.nogi@next-hd.co.jp)でrefresh_tokenが`.env`の`LW_USER_REFRESH_TOKEN`に保存されることを確認。
|
||||
3. `node dbbackup/backup.js create-test-db` でテスト用DBを作成し、`node dbbackup/backup.js run --database=pleasanter_backup_test`(本番`PGDATABASE`には触れない)を一度実行して、
|
||||
- LINE WORKSの「システムバックアップ」共有ドライブに `Pleasanter_DB_Backup/<today>/` が作成され、ダンプファイルが格納されること
|
||||
- `Pleasanter_DB_Backup/logs/backup-<今月>.log` がアップロードされ、開始〜終了までの各ステップが時分秒付き・INFOで記録されていること
|
||||
- `.env` に `LW_SHAREDRIVE_ID` が書き込まれること
|
||||
- ローカル `dbbackup/logs/backup-<今月>.log` にも同内容が残っていること
|
||||
を目視確認。
|
||||
4. 同日にもう一度 `run --database=pleasanter_backup_test` を実行し、同じ日付フォルダ・同じ月次ログファイルが上書き(ログは追記)されること、ダンプファイル名は秒まで異なるため衝突しないことを確認。
|
||||
5. `BACKUP_RETENTION_COUNT` を小さい値(例: 2)に一時変更して連続実行し、古い日付フォルダが削除されローテーションが機能すること、ログに削除対象フォルダがINFOで記録されることを確認。
|
||||
6. 異常系: DB接続情報を意図的に間違え、アップロード・ローテーションに進まず失敗終了すること、ローカルログにERRORが記録されつつ`finally`でログファイル自体はアップロードされることを確認。
|
||||
7. `node dbbackup/backup.js drop-test-db` でテスト用DBを削除し、後始末できることを確認。
|
||||
216
PleasanterSystem/dbbackup/backup.js
Normal file
216
PleasanterSystem/dbbackup/backup.js
Normal file
@ -0,0 +1,216 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* PostgreSQL(Pleasanter) → LINE WORKS共有ドライブ 自動バックアップ
|
||||
* 詳細仕様は SPEC.md 参照。
|
||||
*
|
||||
* 使い方:
|
||||
* node backup.js … バックアップ実行 (run と同じ)
|
||||
* node backup.js run [--database=<name>] … バックアップ実行(DB名を一時的に上書き可能)
|
||||
* node backup.js create-test-db [dbName] … テスト用DB作成
|
||||
* node backup.js drop-test-db [dbName] … テスト用DB削除
|
||||
*
|
||||
* 保存先: 共有ドライブ「システムバックアップ」> Pleasanter_DB_Backup > 日付フォルダ(YYYYMMDD)
|
||||
* ログ: 同ドライブの Pleasanter_DB_Backup/logs 配下に月次ファイルとして上書きアップロード
|
||||
*/
|
||||
|
||||
require("./lib/env").loadEnv();
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const logger = require("./lib/logger");
|
||||
const { getUserAccessToken } = require("./lib/lineworksUserAuth");
|
||||
const drive = require("./lib/lineworksDrive");
|
||||
const { runPgDump, formatTimestamp } = require("./lib/pgDump");
|
||||
const { createTestDb, dropTestDb } = require("./lib/testDb");
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatDateFolderName(date = new Date()) {
|
||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const rest = argv.slice(2);
|
||||
const rawCommand = rest[0];
|
||||
const hasExplicitCommand = rawCommand && !rawCommand.startsWith("--");
|
||||
const command = hasExplicitCommand ? rawCommand : "run";
|
||||
const remaining = hasExplicitCommand ? rest.slice(1) : rest;
|
||||
|
||||
const options = {};
|
||||
const positional = [];
|
||||
for (const arg of remaining) {
|
||||
const m = /^--([^=]+)=(.*)$/.exec(arg);
|
||||
if (m) {
|
||||
options[m[1]] = m[2];
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, options, positional };
|
||||
}
|
||||
|
||||
async function runBackup({ database } = {}) {
|
||||
const startedAt = Date.now();
|
||||
logger.info("===== バックアップ開始 =====");
|
||||
|
||||
let accessToken = null;
|
||||
let sharedriveId = null;
|
||||
let backupFolderId = null;
|
||||
|
||||
try {
|
||||
const pgDumpBin = process.env.PG_DUMP_BIN || "pg_dump";
|
||||
const host = process.env.PGHOST || "localhost";
|
||||
const port = process.env.PGPORT || "5432";
|
||||
const user = process.env.PGUSER || "postgres";
|
||||
const password = process.env.PGPASSWORD || "";
|
||||
const targetDatabase = database || process.env.PGDATABASE || "Implem.Pleasanter";
|
||||
const outputDir = process.env.BACKUP_LOCAL_DIR
|
||||
? path.resolve(process.env.BACKUP_LOCAL_DIR)
|
||||
: path.join(__dirname, "dump_tmp");
|
||||
const splitSizeMb = Number(process.env.PG_DUMP_SPLIT_SIZE_MB || 3072);
|
||||
const timestamp = formatTimestamp();
|
||||
|
||||
logger.info(
|
||||
`pg_dump開始: host=${host} port=${port} user=${user} db=${targetDatabase} 出力先=${outputDir}`
|
||||
);
|
||||
const dumpStartedAt = Date.now();
|
||||
const { files: dumpFiles, totalBytes } = await runPgDump({
|
||||
pgDumpBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database: targetDatabase,
|
||||
outputDir,
|
||||
splitSizeMb,
|
||||
timestamp,
|
||||
});
|
||||
const dumpElapsedSec = ((Date.now() - dumpStartedAt) / 1000).toFixed(1);
|
||||
logger.info(
|
||||
`pg_dump終了: 合計サイズ=${totalBytes}bytes ファイル数=${dumpFiles.length} 所要時間=${dumpElapsedSec}秒`
|
||||
);
|
||||
|
||||
logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)");
|
||||
accessToken = await getUserAccessToken();
|
||||
logger.info("アクセストークン取得成功");
|
||||
|
||||
sharedriveId = await drive.resolveSharedDriveId(accessToken, logger);
|
||||
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(accessToken, sharedriveId, null, rootFolderName, logger);
|
||||
backupFolderId = backupFolder.fileId;
|
||||
|
||||
const dateFolderName = formatDateFolderName();
|
||||
const dateFolder = await drive.ensureFolder(
|
||||
accessToken,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
dateFolderName,
|
||||
logger
|
||||
);
|
||||
|
||||
for (const filePath of dumpFiles) {
|
||||
await drive.uploadFile(accessToken, sharedriveId, dateFolder.fileId, filePath, { overwrite: false }, logger);
|
||||
}
|
||||
|
||||
for (const filePath of dumpFiles) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
logger.info("ローカル一時ファイル削除完了");
|
||||
|
||||
await rotateOldBackups(accessToken, sharedriveId, backupFolderId);
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`===== バックアップ終了(成功) ===== 所要時間=${elapsedSec}秒`);
|
||||
} catch (err) {
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.error(`===== バックアップ終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await uploadLogFile({ accessToken, sharedriveId, backupFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateOldBackups(accessToken, sharedriveId, backupFolderId) {
|
||||
const retentionCount = Number(process.env.BACKUP_RETENTION_COUNT || 100);
|
||||
logger.info(`ローテーション開始 (保持件数=${retentionCount})`);
|
||||
|
||||
const children = await drive.listChildren(accessToken, sharedriveId, backupFolderId, logger);
|
||||
const dateFolders = children
|
||||
.filter((f) => f.fileType === "FOLDER" && /^\d{8}$/.test(f.fileName))
|
||||
.sort((a, b) => (a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0));
|
||||
|
||||
const excess = dateFolders.length - retentionCount;
|
||||
if (excess <= 0) {
|
||||
logger.info(`ローテーション終了: 削除件数=0 (現在${dateFolders.length}件)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toDelete = dateFolders.slice(0, excess);
|
||||
for (const folder of toDelete) {
|
||||
await drive.deleteFile(accessToken, sharedriveId, folder.fileId, logger);
|
||||
logger.info(`削除: フォルダ名=${folder.fileName} fileId=${folder.fileId}`);
|
||||
}
|
||||
logger.info(`ローテーション終了: 削除件数=${toDelete.length}`);
|
||||
}
|
||||
|
||||
// バックアップ成否に関わらず、当月ログファイルを Pleasanter_DB_Backup/logs 配下へ上書きアップロードする。
|
||||
async function uploadLogFile({ accessToken, sharedriveId, backupFolderId }) {
|
||||
logger.info("ログファイルアップロード開始");
|
||||
try {
|
||||
let token = accessToken;
|
||||
let driveId = sharedriveId;
|
||||
let folderId = backupFolderId;
|
||||
|
||||
if (!token) {
|
||||
token = await getUserAccessToken();
|
||||
}
|
||||
if (!driveId) {
|
||||
driveId = await drive.resolveSharedDriveId(token, logger);
|
||||
}
|
||||
if (!folderId) {
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(token, driveId, null, rootFolderName, logger);
|
||||
folderId = backupFolder.fileId;
|
||||
}
|
||||
|
||||
const logFolderName = process.env.LW_LOG_FOLDER_NAME || "logs";
|
||||
const logsFolder = await drive.ensureFolder(token, driveId, folderId, logFolderName, logger);
|
||||
|
||||
const logFilePath = logger.currentLogFilePath();
|
||||
if (fs.existsSync(logFilePath)) {
|
||||
await drive.uploadFile(token, driveId, logsFolder.fileId, logFilePath, { overwrite: true }, logger);
|
||||
logger.info("ログファイルアップロード成功");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`ログファイルアップロード失敗: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { command, options, positional } = parseArgs(process.argv);
|
||||
|
||||
if (command === "run") {
|
||||
await runBackup({ database: options.database });
|
||||
} else if (command === "create-test-db") {
|
||||
const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test";
|
||||
await createTestDb(dbName);
|
||||
} else if (command === "drop-test-db") {
|
||||
const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test";
|
||||
await dropTestDb(dbName);
|
||||
} else {
|
||||
console.log(
|
||||
"使い方: node backup.js [run [--database=<dbName>] | create-test-db [dbName] | drop-test-db [dbName]]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
48
PleasanterSystem/dbbackup/backup.sh
Normal file
48
PleasanterSystem/dbbackup/backup.sh
Normal file
@ -0,0 +1,48 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "start================================"
|
||||
date
|
||||
|
||||
# 日付
|
||||
DATE=`date '+%Y%m%d-%H%M%S'`
|
||||
# リモートディレクトリ
|
||||
REMOTEDIR=$DATE
|
||||
# バックアップ先ディレクトリ
|
||||
SAVEPATH='/var/lib/pgsql/backup/dmp/'
|
||||
# 先頭文字
|
||||
PREFIX='postgres-'
|
||||
# 拡張子
|
||||
EXT='.dmp'
|
||||
# ファイル名
|
||||
FNAME=$PREFIX$DATE$EXT
|
||||
# データーベース名
|
||||
DBNAME='Implem.Pleasanter'
|
||||
# password
|
||||
PASSWORD='TNuLGPdn7FG#zvdTFP%fE4'
|
||||
|
||||
# バックアップ実行
|
||||
echo "run pg_dump"
|
||||
/usr/bin/pg_dump -h localhost -U postgres -Fc $DBNAME | split -d -a 3 -b 3072m - $SAVEPATH$FNAME
|
||||
|
||||
#expect -c "
|
||||
#set timeout 14400
|
||||
#spawn bash -c \"/usr/bin/pg_dump -h localhost -U postgres -Fc $DBNAME | split -d -a 3 -b 3072m - $SAVEPATH$FNAME\"
|
||||
#expect \"パスワード:\"
|
||||
#send ${PASSWORD}\n
|
||||
#interact
|
||||
#"
|
||||
|
||||
date
|
||||
# 転送
|
||||
# for U_FILE in `ls $SAVEPATH$FNAME*`; do
|
||||
# sleep 5
|
||||
# U_FILE=`basename $U_FILE`
|
||||
# date
|
||||
# echo "upload $U_FILE"
|
||||
# /var/lib/pgsql/backup/sftp.sh $SAVEPATH $U_FILE $REMOTEDIR
|
||||
# echo "rm $SAVEPATH$U_FILE"
|
||||
# rm $SAVEPATH$U_FILE
|
||||
# done
|
||||
|
||||
date
|
||||
echo "end=================================="
|
||||
63
PleasanterSystem/dbbackup/get-token.js
Normal file
63
PleasanterSystem/dbbackup/get-token.js
Normal file
@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/*
|
||||
* アクセストークンだけを標準出力するヘルパー
|
||||
* JWT組み立て・トークン取得の実体は lib/lineworksAuth.js (backup.js と共通)
|
||||
*
|
||||
* 使い方:
|
||||
* node get-token.js
|
||||
* → アクセストークン文字列だけが標準出力される(それ以外は何も出力しない)
|
||||
*
|
||||
* 必要な環境変数は .env に必ず設定すること(コード内にデフォルト値は持たない):
|
||||
* LW_CLIENT_ID / LW_CLIENT_SECRET / LW_SERVICE_ACCOUNT / LW_SCOPE
|
||||
* LW_PRIVATE_KEY または LW_PRIVATE_KEY_FILE
|
||||
* いずれかが未設定の場合は何も実行せずエラー終了する。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
require("./lib/env").loadEnv();
|
||||
const { getAccessToken, resolvePrivateKeyFile } = require("./lib/lineworksAuth");
|
||||
|
||||
const LW_CLIENT_ID = process.env.LW_CLIENT_ID;
|
||||
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET;
|
||||
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT;
|
||||
const LW_SCOPE = process.env.LW_SCOPE;
|
||||
const PRIVATE_KEY_FILE = resolvePrivateKeyFile();
|
||||
|
||||
function checkRequiredEnv() {
|
||||
const missing = [];
|
||||
if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID");
|
||||
if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET");
|
||||
if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT");
|
||||
if (!LW_SCOPE) missing.push("LW_SCOPE");
|
||||
if (!process.env.LW_PRIVATE_KEY && !fs.existsSync(PRIVATE_KEY_FILE)) {
|
||||
missing.push(`LW_PRIVATE_KEY_FILE (${PRIVATE_KEY_FILE} が存在しません)`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const missing = checkRequiredEnv();
|
||||
if (missing.length > 0) {
|
||||
process.stderr.write(`.env に必要な環境変数が設定されていません: ${missing.join(", ")}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// デバッグ用: 実際に使われている値をstderrに出力(トークン取得の可否には影響しない)
|
||||
process.stderr.write(`[DEBUG] LW_CLIENT_ID=${LW_CLIENT_ID}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_SERVICE_ACCOUNT=${LW_SERVICE_ACCOUNT}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_PRIVATE_KEY_FILE=${PRIVATE_KEY_FILE}\n`);
|
||||
process.stderr.write(`[DEBUG] 秘密鍵ファイル存在=${fs.existsSync(PRIVATE_KEY_FILE)}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_SCOPE=${LW_SCOPE}\n`);
|
||||
|
||||
try {
|
||||
const token = await getAccessToken({ scope: LW_SCOPE });
|
||||
process.stdout.write(token);
|
||||
} catch (err) {
|
||||
process.stderr.write(`アクセストークン取得失敗: ${err.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
61
PleasanterSystem/dbbackup/lib/env.js
Normal file
61
PleasanterSystem/dbbackup/lib/env.js
Normal file
@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ENV_PATH = path.join(__dirname, "..", ".env");
|
||||
|
||||
function parseLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return null;
|
||||
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) return null;
|
||||
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
||||
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
// .env を読み込み process.env に反映する。.env ファイルの値を常に正として扱い、
|
||||
// 実行環境に同名の変数が既に存在していても .env の値で上書きする。
|
||||
function loadEnv(envPath = ENV_PATH) {
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
|
||||
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const parsed = parseLine(line);
|
||||
if (!parsed) continue;
|
||||
process.env[parsed.key] = parsed.value;
|
||||
}
|
||||
}
|
||||
|
||||
// .env 内の KEY=... 行を書き換える(無ければ末尾に追加)。process.env も更新する。
|
||||
// LW_SHAREDRIVE_ID の永続化に使用する。
|
||||
function updateEnvValue(key, value, envPath = ENV_PATH) {
|
||||
const text = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
||||
const linePattern = new RegExp(`^${key}=.*$`, "m");
|
||||
const newLine = `${key}=${value}`;
|
||||
|
||||
let newText;
|
||||
if (linePattern.test(text)) {
|
||||
newText = text.replace(linePattern, newLine);
|
||||
} else {
|
||||
const separator = text.length === 0 || text.endsWith("\n") ? "" : "\n";
|
||||
newText = `${text}${separator}${newLine}\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, newText, "utf8");
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
module.exports = { loadEnv, updateEnvValue, ENV_PATH };
|
||||
115
PleasanterSystem/dbbackup/lib/lineworksAuth.js
Normal file
115
PleasanterSystem/dbbackup/lib/lineworksAuth.js
Normal file
@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS Service AccountのJWT自己署名 → OAuth2アクセストークン取得。
|
||||
* get-token.js (CLI、掲示板等のService Account対応APIの動作確認用) が使用する。
|
||||
* Drive/共有ドライブAPIはService Account認証に非対応のため、backup.jsは
|
||||
* 代わりに lib/lineworksUserAuth.js (User Account認証) を使用する。
|
||||
* ロジックは元々 get-token.js / lineworks-anythingllm.js にあったものを移設。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
|
||||
|
||||
function resolvePrivateKeyFile() {
|
||||
return process.env.LW_PRIVATE_KEY_FILE
|
||||
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
|
||||
: path.join(__dirname, "..", "private_20260307184804.key");
|
||||
}
|
||||
|
||||
function resolvePrivateKey() {
|
||||
if (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim()) {
|
||||
return process.env.LW_PRIVATE_KEY;
|
||||
}
|
||||
const file = resolvePrivateKeyFile();
|
||||
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
}
|
||||
|
||||
function base64UrlEncode(value) {
|
||||
return Buffer.from(value)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function createJwtAssertion({ clientId, serviceAccount, privateKey }) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: "RS256", typ: "JWT" };
|
||||
const payload = {
|
||||
iss: clientId,
|
||||
sub: serviceAccount,
|
||||
aud: LW_TOKEN_URL,
|
||||
iat: now,
|
||||
exp: now + 300,
|
||||
};
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signer = crypto.createSign("RSA-SHA256");
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
|
||||
const signature = signer
|
||||
.sign(privateKey)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
// clientId/clientSecret/serviceAccount にコード側のデフォルト値は持たない。
|
||||
// 必ず .env(または呼び出し元が明示的に渡すoptions)から取得し、無ければ即座にエラーとする。
|
||||
// scope は呼び出し元(get-token.js / backup.js)ごとに用途が異なるため必須の引数として扱う。
|
||||
async function getAccessToken(options = {}) {
|
||||
const clientId = options.clientId || process.env.LW_CLIENT_ID;
|
||||
const clientSecret = options.clientSecret || process.env.LW_CLIENT_SECRET;
|
||||
const serviceAccount = options.serviceAccount || process.env.LW_SERVICE_ACCOUNT;
|
||||
const scope = options.scope !== undefined ? options.scope : process.env.LW_SCOPE;
|
||||
const privateKey = options.privateKey || resolvePrivateKey();
|
||||
|
||||
const missing = [];
|
||||
if (!clientId) missing.push("LW_CLIENT_ID");
|
||||
if (!clientSecret) missing.push("LW_CLIENT_SECRET");
|
||||
if (!serviceAccount) missing.push("LW_SERVICE_ACCOUNT");
|
||||
if (!privateKey) missing.push(`LW_PRIVATE_KEY または LW_PRIVATE_KEY_FILE (${resolvePrivateKeyFile()} が存在しません)`);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`環境変数が不足しています: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const assertion = createJwtAssertion({ clientId, serviceAccount, privateKey });
|
||||
const form = new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: scope || "",
|
||||
});
|
||||
|
||||
const response = await fetch(LW_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) {
|
||||
throw new Error("アクセストークン取得失敗: access_token が返却されませんでした");
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
module.exports = { getAccessToken, resolvePrivateKeyFile, resolvePrivateKey };
|
||||
276
PleasanterSystem/dbbackup/lib/lineworksDrive.js
Normal file
276
PleasanterSystem/dbbackup/lib/lineworksDrive.js
Normal file
@ -0,0 +1,276 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS 共有ドライブAPIのラッパー。
|
||||
* 仕様書:
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-list
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-file-folder-create
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-file-create
|
||||
* https://developers.worksmobile.com/en/docs/file-upload (アップロード段階2の詳細)
|
||||
*
|
||||
* 必要スコープ: file (読み書き全般)
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const { updateEnvValue } = require("./env");
|
||||
const defaultLogger = require("./logger");
|
||||
|
||||
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
|
||||
|
||||
// 各API呼び出しの間隔(ミリ秒)とレート制限時の最大リトライ回数。
|
||||
// lineworks-anythingllm.js の lwFetch と同じスロットリング方式。
|
||||
const LW_REQUEST_DELAY_MS = Number(process.env.LW_REQUEST_DELAY_MS || 250);
|
||||
const LW_MAX_RETRIES = Number(process.env.LW_MAX_RETRIES || 5);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function lwFetch(url, options = {}, logger = defaultLogger) {
|
||||
for (let attempt = 0; attempt <= LW_MAX_RETRIES; attempt++) {
|
||||
await sleep(LW_REQUEST_DELAY_MS);
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.status !== 429) {
|
||||
return response;
|
||||
}
|
||||
if (attempt === LW_MAX_RETRIES) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfterMs = retryAfterHeader
|
||||
? Number(retryAfterHeader) * 1000
|
||||
: LW_REQUEST_DELAY_MS * Math.pow(2, attempt + 1);
|
||||
|
||||
logger.warn(
|
||||
`[Rate Limit] 429を検知。${Math.round(retryAfterMs / 1000)}秒待機してリトライします... ` +
|
||||
`(${attempt + 1}/${LW_MAX_RETRIES}) url=${url}`
|
||||
);
|
||||
await sleep(retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response, errorLabel) {
|
||||
const raw = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch (_err) {
|
||||
throw new Error(`${errorLabel}のレスポンスがJSONではありません: ${raw}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${errorLabel}失敗: ${response.status} ${JSON.stringify(data)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 共有ドライブID解決
|
||||
// ====================================================================
|
||||
|
||||
// GET /sharedrives にはページング用パラメータ(count/cursor)が存在しない
|
||||
// (/files 系のようなresponseMetaData.nextCursorは返らない)ため、1回のみ呼び出す。
|
||||
async function listSharedDrives(token, logger = defaultLogger) {
|
||||
const res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
logger
|
||||
);
|
||||
const data = await parseJsonResponse(res, "共有ドライブ一覧取得");
|
||||
|
||||
return Array.isArray(data.sharedrives) ? data.sharedrives : [];
|
||||
}
|
||||
|
||||
// .env の LW_SHAREDRIVE_ID があればそれを使用(APIを呼ばない)。無ければ
|
||||
// LW_SHAREDRIVE_NAME と一致する共有ドライブを検索し、.env に書き戻して次回以降キャッシュする。
|
||||
async function resolveSharedDriveId(token, logger = defaultLogger) {
|
||||
const cached = process.env.LW_SHAREDRIVE_ID;
|
||||
if (cached) {
|
||||
logger.info(`共有ドライブID: キャッシュ値を使用 (${cached})`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const targetName = process.env.LW_SHAREDRIVE_NAME || "システムバックアップ";
|
||||
logger.info(`共有ドライブID未キャッシュ。名前で検索します: ${targetName}`);
|
||||
|
||||
const drives = await listSharedDrives(token, logger);
|
||||
const found = drives.find((d) => d.name === targetName);
|
||||
if (!found) {
|
||||
throw new Error(`共有ドライブが見つかりません: ${targetName}`);
|
||||
}
|
||||
|
||||
updateEnvValue("LW_SHAREDRIVE_ID", found.sharedriveId);
|
||||
logger.info(`共有ドライブID解決: name=${targetName} id=${found.sharedriveId} (.envへ保存)`);
|
||||
|
||||
return found.sharedriveId;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// フォルダ一覧・作成
|
||||
// ====================================================================
|
||||
|
||||
// parentFileId が falsy の場合はルート直下を対象にする。
|
||||
async function listChildren(token, sharedriveId, parentFileId, logger = defaultLogger) {
|
||||
const files = [];
|
||||
let cursor = "";
|
||||
const basePath = parentFileId
|
||||
? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/children`
|
||||
: `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files`;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("count", "200");
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
|
||||
const res = await lwFetch(
|
||||
`${basePath}?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
logger
|
||||
);
|
||||
const data = await parseJsonResponse(res, "ファイル一覧取得");
|
||||
|
||||
const page = Array.isArray(data.files) ? data.files : [];
|
||||
files.push(...page);
|
||||
|
||||
const nextCursor = data?.responseMetaData?.nextCursor || "";
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function createFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) {
|
||||
const url = parentFileId
|
||||
? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/createfolder`
|
||||
: `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/createfolder`;
|
||||
|
||||
const res = await lwFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ fileName: folderName }),
|
||||
},
|
||||
logger
|
||||
);
|
||||
|
||||
return parseJsonResponse(res, `フォルダ作成 (${folderName})`);
|
||||
}
|
||||
|
||||
// 同名フォルダが既にあれば再利用し、無ければ作成する。
|
||||
async function ensureFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) {
|
||||
const children = await listChildren(token, sharedriveId, parentFileId, logger);
|
||||
const existing = children.find((f) => f.fileType === "FOLDER" && f.fileName === folderName);
|
||||
if (existing) {
|
||||
logger.info(`フォルダ確認: 既存を使用 name=${folderName} id=${existing.fileId}`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await createFolder(token, sharedriveId, parentFileId, folderName, logger);
|
||||
logger.info(`フォルダ確認: 新規作成 name=${folderName} id=${created.fileId}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// アップロード (段階1: uploadUrl発行 → 段階2: multipart/form-dataで実バイト送信)
|
||||
// ====================================================================
|
||||
|
||||
async function fileToBlob(filePath, mimeType) {
|
||||
if (typeof fs.openAsBlob === "function") {
|
||||
// Node 18.13+: ファイル全体をメモリに載せずBlobとして扱える
|
||||
return fs.openAsBlob(filePath, { type: mimeType });
|
||||
}
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return new Blob([buffer], { type: mimeType });
|
||||
}
|
||||
|
||||
async function uploadFile(token, sharedriveId, parentFileId, filePath, options = {}, logger = defaultLogger) {
|
||||
const overwrite = options.overwrite === true;
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
logger.info(`アップロード開始: ファイル名=${fileName} サイズ=${stat.size}bytes`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
const stage1Res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName,
|
||||
modifiedTime: new Date(stat.mtimeMs).toISOString(),
|
||||
fileSize: stat.size,
|
||||
overwrite,
|
||||
resume: false,
|
||||
suffixOnDuplicate: false,
|
||||
}),
|
||||
},
|
||||
logger
|
||||
);
|
||||
const stage1Data = await parseJsonResponse(stage1Res, `アップロードURL発行 (${fileName})`);
|
||||
if (!stage1Data.uploadUrl) {
|
||||
throw new Error(`アップロードURL発行失敗 (${fileName}): uploadUrlが返却されませんでした`);
|
||||
}
|
||||
|
||||
const blob = await fileToBlob(filePath, "application/octet-stream");
|
||||
const form = new FormData();
|
||||
form.append("Filedata", blob, fileName);
|
||||
|
||||
const stage2Res = await lwFetch(
|
||||
stage1Data.uploadUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
},
|
||||
logger
|
||||
);
|
||||
const stage2Data = await parseJsonResponse(stage2Res, `アップロード (${fileName})`);
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`アップロード成功: ファイル名=${fileName} fileId=${stage2Data.fileId} 所要時間=${elapsedSec}秒`);
|
||||
|
||||
return stage2Data;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 削除
|
||||
// ====================================================================
|
||||
|
||||
async function deleteFile(token, sharedriveId, fileId, logger = defaultLogger) {
|
||||
const res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(fileId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
logger
|
||||
);
|
||||
|
||||
if (res.status !== 204 && !res.ok) {
|
||||
const raw = await res.text();
|
||||
throw new Error(`削除失敗 (${fileId}): ${res.status} ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LW_API_BASE_URL,
|
||||
listSharedDrives,
|
||||
resolveSharedDriveId,
|
||||
listChildren,
|
||||
ensureFolder,
|
||||
uploadFile,
|
||||
deleteFile,
|
||||
};
|
||||
120
PleasanterSystem/dbbackup/lib/lineworksUserAuth.js
Normal file
120
PleasanterSystem/dbbackup/lib/lineworksUserAuth.js
Normal file
@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS User Account認証 (OAuth 2.0 認可コードフロー)。
|
||||
* Drive/共有ドライブAPIはService Account認証(JWT)では利用できないため、
|
||||
* backup.js のドライブ操作はこちらの実ユーザー認証を使う。
|
||||
*
|
||||
* 初回セットアップ: `node lineworks-authorize.js` を一度だけ実行し、
|
||||
* ブラウザでの認可 → refresh_token取得 → .envへの保存 を行う。
|
||||
* 以降 backup.js は getUserAccessToken() で refresh_token から
|
||||
* access_token を都度再発行する(ユーザー操作不要)。
|
||||
*
|
||||
* 仕様書: https://developers.worksmobile.com/jp/docs/auth-oauth
|
||||
*/
|
||||
|
||||
const { updateEnvValue } = require("./env");
|
||||
|
||||
const LW_AUTHORIZE_URL = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
|
||||
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
|
||||
|
||||
function requiredEnv(key) {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`環境変数が不足しています: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl({ state }) {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const redirectUri = requiredEnv("LW_REDIRECT_URI");
|
||||
const scope = requiredEnv("LW_SCOPE_DRIVE");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope,
|
||||
state,
|
||||
});
|
||||
|
||||
return `${LW_AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function requestToken(form) {
|
||||
const response = await fetch(LW_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch (_err) {
|
||||
throw new Error(`トークンエンドポイントのレスポンスがJSONではありません: ${raw}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`トークン取得失敗: ${response.status} ${raw}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code) {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const clientSecret = requiredEnv("LW_CLIENT_SECRET");
|
||||
const redirectUri = requiredEnv("LW_REDIRECT_URI");
|
||||
|
||||
return requestToken(
|
||||
new URLSearchParams({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// refresh_token から access_token を再発行する。Refresh Token Rotation が ON のため、
|
||||
// レスポンスに含まれる新しい refresh_token を必ず .env へ書き戻してから access_token を返す。
|
||||
async function getUserAccessToken() {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const clientSecret = requiredEnv("LW_CLIENT_SECRET");
|
||||
const refreshToken = process.env.LW_USER_REFRESH_TOKEN;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
"LW_USER_REFRESH_TOKEN が未設定です。先に 'node lineworks-authorize.js' を実行してください。"
|
||||
);
|
||||
}
|
||||
|
||||
const data = await requestToken(
|
||||
new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
);
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("アクセストークン再発行失敗: access_token が返却されませんでした");
|
||||
}
|
||||
|
||||
if (data.refresh_token) {
|
||||
updateEnvValue("LW_USER_REFRESH_TOKEN", data.refresh_token);
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForToken,
|
||||
getUserAccessToken,
|
||||
LW_AUTHORIZE_URL,
|
||||
LW_TOKEN_URL,
|
||||
};
|
||||
62
PleasanterSystem/dbbackup/lib/logger.js
Normal file
62
PleasanterSystem/dbbackup/lib/logger.js
Normal file
@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* 月次ローテーションするローカルログファイルへの書き込み。
|
||||
* 出力先: LOG_LOCAL_DIR/backup-YYYYMM.log (月が変わると自動的に別ファイルへ切り替わる)
|
||||
* 各行: YYYY-MM-DD HH:mm:ss [LEVEL] message
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function pad(n, width = 2) {
|
||||
return String(n).padStart(width, "0");
|
||||
}
|
||||
|
||||
function timestamp(date = new Date()) {
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// LOG_LOCAL_DIR は実行時に .env 読み込み後の process.env を見たいため、
|
||||
// モジュール読み込み時点では確定させず呼び出しの都度評価する。
|
||||
function getLogDir() {
|
||||
return process.env.LOG_LOCAL_DIR
|
||||
? path.resolve(process.env.LOG_LOCAL_DIR)
|
||||
: path.join(__dirname, "..", "logs");
|
||||
}
|
||||
|
||||
function currentLogFilePath(date = new Date()) {
|
||||
const yyyymm = `${date.getFullYear()}${pad(date.getMonth() + 1)}`;
|
||||
return path.join(getLogDir(), `backup-${yyyymm}.log`);
|
||||
}
|
||||
|
||||
function writeLine(level, message) {
|
||||
const line = `${timestamp()} [${level}] ${message}`;
|
||||
|
||||
if (level === "WARN") {
|
||||
console.warn(line);
|
||||
} else if (level === "ERROR") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
try {
|
||||
const logDir = getLogDir();
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.appendFileSync(currentLogFilePath(), line + "\n", "utf8");
|
||||
} catch (err) {
|
||||
console.error(`[logger] ログファイルへの書き込みに失敗しました: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
info: (message) => writeLine("INFO", message),
|
||||
warn: (message) => writeLine("WARN", message),
|
||||
error: (message) => writeLine("ERROR", message),
|
||||
currentLogFilePath,
|
||||
getLogDir,
|
||||
};
|
||||
118
PleasanterSystem/dbbackup/lib/pgDump.js
Normal file
118
PleasanterSystem/dbbackup/lib/pgDump.js
Normal file
@ -0,0 +1,118 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* pg_dump をspawnし、標準出力を splitSizeMb ごとにローカルファイルへ分割書き出しする。
|
||||
* backup.sh の `pg_dump ... | split -d -a 3 -b 3072m - $SAVEPATH$FNAME` と同じ
|
||||
* 3桁ゼロ埋め連番(000, 001, ...)をファイル名末尾に付与する。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatTimestamp(date = new Date()) {
|
||||
return (
|
||||
`${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-` +
|
||||
`${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
function runPgDump({
|
||||
pgDumpBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
outputDir,
|
||||
splitSizeMb,
|
||||
prefix = "postgres-",
|
||||
ext = ".dmp",
|
||||
timestamp = formatTimestamp(),
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const splitSizeBytes = Number(splitSizeMb) * 1024 * 1024;
|
||||
const baseName = `${prefix}${timestamp}${ext}`;
|
||||
|
||||
const args = ["-h", host, "-p", String(port), "-U", user, "-Fc", database];
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
env.PGPASSWORD = password;
|
||||
}
|
||||
|
||||
const child = spawn(pgDumpBin, args, { env });
|
||||
|
||||
const files = [];
|
||||
let partIndex = 0;
|
||||
let currentStream = null;
|
||||
let currentBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let stderrOutput = "";
|
||||
let settled = false;
|
||||
|
||||
function openNextPart() {
|
||||
const fileName = `${baseName}${String(partIndex).padStart(3, "0")}`;
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
files.push(filePath);
|
||||
currentStream = fs.createWriteStream(filePath);
|
||||
currentBytes = 0;
|
||||
partIndex += 1;
|
||||
}
|
||||
|
||||
openNextPart();
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
if (currentBytes >= splitSizeBytes) {
|
||||
currentStream.end();
|
||||
openNextPart();
|
||||
}
|
||||
const remaining = splitSizeBytes - currentBytes;
|
||||
const writeLen = Math.min(remaining, chunk.length - offset);
|
||||
currentStream.write(chunk.subarray(offset, offset + writeLen));
|
||||
currentBytes += writeLen;
|
||||
totalBytes += writeLen;
|
||||
offset += writeLen;
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrOutput += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (currentStream) currentStream.end();
|
||||
reject(new Error(`pg_dump起動失敗: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
|
||||
const finalize = () => {
|
||||
settled = true;
|
||||
if (code !== 0) {
|
||||
reject(new Error(`pg_dump異常終了 (code=${code}): ${stderrOutput.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ files, totalBytes, stderrOutput: stderrOutput.trim() });
|
||||
};
|
||||
|
||||
if (currentStream && !currentStream.destroyed) {
|
||||
currentStream.end(finalize);
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runPgDump, formatTimestamp };
|
||||
71
PleasanterSystem/dbbackup/lib/testDb.js
Normal file
71
PleasanterSystem/dbbackup/lib/testDb.js
Normal file
@ -0,0 +1,71 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* バックアップパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を
|
||||
* 本番DBに触れずに試せるよう、テスト用DBを作成・削除するヘルパー。
|
||||
* PGHOST/PGPORT/PGUSER/PGPASSWORD は通常バックアップと同じ接続情報を再利用する。
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function runCommand(bin, args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
|
||||
child.on("error", (err) => reject(new Error(`${bin} 起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`${bin} 異常終了 (code=${code}): ${(stderr || stdout).trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildEnv() {
|
||||
const env = { ...process.env };
|
||||
if (process.env.PGPASSWORD) {
|
||||
env.PGPASSWORD = process.env.PGPASSWORD;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function connectionInfo() {
|
||||
return {
|
||||
host: process.env.PGHOST || "localhost",
|
||||
port: process.env.PGPORT || "5432",
|
||||
user: process.env.PGUSER || "postgres",
|
||||
};
|
||||
}
|
||||
|
||||
async function createTestDb(dbName) {
|
||||
const bin = process.env.PG_CREATEDB_BIN || "createdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
console.log(`テスト用DB作成: ${dbName} (host=${host} port=${port} user=${user})`);
|
||||
try {
|
||||
await runCommand(bin, ["-h", host, "-p", String(port), "-U", user, dbName], buildEnv());
|
||||
console.log(`テスト用DB作成完了: ${dbName}`);
|
||||
} catch (err) {
|
||||
console.error(`テスト用DB作成失敗: ${err.message}`);
|
||||
console.error(`既に存在する場合は先に 'node backup.js drop-test-db ${dbName}' を実行してください。`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function dropTestDb(dbName) {
|
||||
const bin = process.env.PG_DROPDB_BIN || "dropdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
console.log(`テスト用DB削除: ${dbName} (host=${host} port=${port} user=${user})`);
|
||||
await runCommand(bin, ["-h", host, "-p", String(port), "-U", user, "--if-exists", dbName], buildEnv());
|
||||
console.log(`テスト用DB削除完了: ${dbName}`);
|
||||
}
|
||||
|
||||
module.exports = { createTestDb, dropTestDb };
|
||||
94
PleasanterSystem/dbbackup/lineworks-authorize.js
Normal file
94
PleasanterSystem/dbbackup/lineworks-authorize.js
Normal file
@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* User Account認証(OAuth 2.0 認可コードフロー)の一回限りのセットアップスクリプト。
|
||||
* Drive/共有ドライブAPIはService Account認証では利用できないため、実ユーザーの
|
||||
* 認可を一度だけ行い、refresh_tokenを.envに保存する。以降のbackup.js実行は
|
||||
* このrefresh_tokenからaccess_tokenを自動再発行する(ユーザー操作不要)。
|
||||
*
|
||||
* 事前準備:
|
||||
* Developer Consoleの「User Account認証」欄の Redirect URL に、
|
||||
* .env の LW_REDIRECT_URI と完全一致する値を登録しておくこと。
|
||||
*
|
||||
* 使い方:
|
||||
* node lineworks-authorize.js
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const readline = require("readline");
|
||||
|
||||
const { loadEnv, updateEnvValue } = require("./lib/env");
|
||||
loadEnv();
|
||||
|
||||
const { buildAuthorizeUrl, exchangeCodeForToken } = require("./lib/lineworksUserAuth");
|
||||
|
||||
function prompt(question) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// リダイレクト後のURL全体、または code の値そのものが貼り付けられるケースの両方に対応する。
|
||||
function extractCode(input) {
|
||||
if (input.includes("code=")) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const code = url.searchParams.get("code");
|
||||
if (code) return code;
|
||||
} catch (_err) {
|
||||
const match = input.match(/[?&]code=([^&\s]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
const authorizeUrl = buildAuthorizeUrl({ state });
|
||||
|
||||
console.log("以下のURLをブラウザで開き、対象のLINE WORKSアカウントでログイン・同意してください:");
|
||||
console.log("");
|
||||
console.log(authorizeUrl);
|
||||
console.log("");
|
||||
console.log("同意後、Redirect URLへ遷移します(ページ自体が表示されなくても問題ありません)。");
|
||||
console.log("遷移後のブラウザのアドレスバーのURL全体(または code= の値だけ)をコピーしてください。");
|
||||
console.log("");
|
||||
|
||||
const input = await prompt("リダイレクト後のURL(またはcode)を貼り付けてEnter: ");
|
||||
const code = extractCode(input);
|
||||
|
||||
if (!code) {
|
||||
console.error("codeを取得できませんでした。");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("アクセストークンに交換しています...");
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
|
||||
if (!tokenData.refresh_token) {
|
||||
console.error("レスポンスに refresh_token が含まれていませんでした。");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
updateEnvValue("LW_USER_REFRESH_TOKEN", tokenData.refresh_token);
|
||||
|
||||
console.log("");
|
||||
console.log("成功しました。");
|
||||
console.log(` scope: ${tokenData.scope}`);
|
||||
console.log(` access_tokenの有効期限: ${tokenData.expires_in}秒`);
|
||||
console.log(" refresh_token を .env の LW_USER_REFRESH_TOKEN に保存しました。");
|
||||
console.log("");
|
||||
console.log("これで 'node backup.js run' が共有ドライブへアクセスできるようになります。");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`セットアップに失敗しました: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
16
PleasanterSystem/dbbackup/package.json
Normal file
16
PleasanterSystem/dbbackup/package.json
Normal file
@ -0,0 +1,16 @@
|
||||
{
|
||||
"name": "dbbackup",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "PostgreSQL(Pleasanter)のバックアップをLINE WORKS共有ドライブへ自動アップロードする",
|
||||
"main": "backup.js",
|
||||
"scripts": {
|
||||
"backup": "node backup.js run",
|
||||
"create-test-db": "node backup.js create-test-db",
|
||||
"drop-test-db": "node backup.js drop-test-db"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.13.0"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
31
PleasanterSystem/dbbackup/sftp.sh
Normal file
31
PleasanterSystem/dbbackup/sftp.sh
Normal file
@ -0,0 +1,31 @@
|
||||
#!/bin/bash
|
||||
|
||||
PASSWORD='Ml\$SRpnh##MrOo4'
|
||||
#REMOTEDIR=`date "+%Y%m%d%H%M%S"`
|
||||
LOCALDIR=$1
|
||||
FILENAME=$2
|
||||
REMOTEDIR=$3
|
||||
|
||||
echo ${REMOTEDIR}
|
||||
|
||||
expect -c "
|
||||
set timeout 7200
|
||||
spawn sftp -P 43622 sftpadmin@vpn823121306.softether.net
|
||||
expect \"password:\"
|
||||
send ${PASSWORD}\n
|
||||
expect \"sftp>\"
|
||||
send \"cd PleasanterBackup\n\"
|
||||
expect \"sftp>\"
|
||||
send \"mkdir ${REMOTEDIR}\n\"
|
||||
expect \"sftp>\"
|
||||
send \"cd ${REMOTEDIR}\n\"
|
||||
expect \"sftp>\"
|
||||
send \"lcd ${LOCALDIR}\n\"
|
||||
expect \"sftp>\"
|
||||
send \"put ${FILENAME}\n\"
|
||||
expect \"sftp>\"
|
||||
send \"quit\n\"
|
||||
|
||||
interact
|
||||
"
|
||||
|
||||
BIN
dbbackup-full.zip
Normal file
BIN
dbbackup-full.zip
Normal file
Binary file not shown.
7
dbbackup/.gitignore
vendored
Normal file
7
dbbackup/.gitignore
vendored
Normal file
@ -0,0 +1,7 @@
|
||||
# 認証情報・秘密鍵(絶対にコミットしない)
|
||||
.env
|
||||
private_*.key
|
||||
|
||||
# ローカル一時ファイル
|
||||
/logs/
|
||||
/dump_tmp/
|
||||
67
dbbackup/HANDOFF.md
Normal file
67
dbbackup/HANDOFF.md
Normal file
@ -0,0 +1,67 @@
|
||||
# 引き継ぎメモ (サーバー設置作業用)
|
||||
|
||||
このメモは、開発機(Windows)でのやり取りを踏まえて、サーバー上のClaudeに引き継ぐための現状整理です。設計の詳細は `SPEC.md` を参照してください。ここでは **「今どこまで終わっていて、次に何をすべきか」** に絞って書きます。
|
||||
|
||||
## 現状ステータス(結論から)
|
||||
|
||||
- コードは一式実装済み・構文チェック済み(Windows開発機で `node --check` 全ファイルOK)。
|
||||
- **未完了・最大のブロッカー**: LINE WORKSの共有ドライブAPIは **Service Account認証(JWT)では利用できない**ことが判明した(公式ドキュメント明記 + 実機検証で確認)。そのため、共有ドライブ操作(backup.jsのアップロード/フォルダ作成/削除/一覧)には **User Account認証(OAuth 2.0 認可コードフロー)** が必須で、これは**人間が一度だけブラウザでログイン・同意する手順**が必要。この手順がまだ実施されていない(`.env` の `LW_USER_REFRESH_TOKEN` が空)。
|
||||
- pg_dump / createdb / dropdb は開発機(Windows)には存在しないため未検証。実サーバー(Linux、PostgreSQL稼働環境)で初めて実地検証できる。
|
||||
- コード一式はまだgitにコミットされていない(ローカルファイルのみ)。`.env` と秘密鍵ファイルは `.gitignore` 済みなのでgit経由では転送されない → **サーバーへは別途安全な方法で転送する必要がある**。
|
||||
|
||||
## サーバーでまずやること(優先順)
|
||||
|
||||
1. **リポジトリ配置**: `dbbackup/` フォルダ一式をサーバーに配置(gitで転送する場合、`.env` と `private_*.key` は `.gitignore` 対象のため別送が必要)。
|
||||
2. **Node.jsバージョン確認**: `node -v` が `18.13.0` 以上であること(`fs.openAsBlob` を使用しているため)。
|
||||
3. **`.env` の内容確認・上書き**:
|
||||
- PostgreSQL接続系(`PG_DUMP_BIN`, `PGHOST`, `PGPORT`, `PGUSER`, `PGPASSWORD`, `PGDATABASE`)をサーバーの実環境に合わせて確認・修正。特に `PG_DUMP_BIN=/usr/bin/pg_dump` のパスが実際に存在するか確認。
|
||||
- `BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/` が書き込み可能なディレクトリか確認。
|
||||
- LINE WORKS認証情報は現在下記の**新しいService Account**が設定済み(旧アカウントから切り替え済み):
|
||||
- `LW_CLIENT_ID=LFE8mn9WwDXOfESHLGha`
|
||||
- `LW_SERVICE_ACCOUNT=dxkz7.serviceaccount@next-hd.co.jp`
|
||||
- `LW_PRIVATE_KEY_FILE=./private_20260711105832.key`(このファイルもサーバーへ配置必須)
|
||||
4. **User Account認証のセットアップ(最重要・未完了)**:
|
||||
- Developer Consoleの「User Account認証」欄のRedirect URLに、`.env` の `LW_REDIRECT_URI`(現状 `https://example.com/callback`)と**完全一致**する値を登録する。サーバー環境でこの値を変える場合は `.env` も同時に書き換えること。
|
||||
- サーバー上(またはブラウザ操作可能な端末)で `node lineworks-authorize.js` を実行し、対話プロンプトの指示に従って:
|
||||
1. 表示された認可URLをブラウザで開く
|
||||
2. `kenichiro.nogi@next-hd.co.jp` でログイン・同意(**パスワードはブラウザのLINE WORKSログイン画面のみに入力し、スクリプトやAIには絶対に渡さない**)
|
||||
3. リダイレクト後のURL(または `code=` の値)をターミナルに貼り付け
|
||||
- 成功すると `refresh_token` が `.env` の `LW_USER_REFRESH_TOKEN` に自動保存される。Refresh Token Rotationが有効なため、以後 `backup.js` 実行のたびに新しい `refresh_token` が自動で書き戻される(手動操作は初回のみでよい)。
|
||||
5. **動作確認**:
|
||||
```
|
||||
node backup.js create-test-db
|
||||
node backup.js run --database=pleasanter_backup_test
|
||||
node backup.js drop-test-db
|
||||
```
|
||||
`SPEC.md` の「動作確認方法」セクションに詳細な確認観点あり(共有ドライブへのフォルダ作成、ログアップロード、`.env`への`LW_SHAREDRIVE_ID`書き込み等)。
|
||||
6. 問題なければ、`.env` の `PGDATABASE` を実データベース名(既定 `Implem.Pleasanter`)に戻して本番実行、その後cron等への登録を検討。
|
||||
|
||||
## これまでの重要な調査結果(繰り返し確認しないための記録)
|
||||
|
||||
- **「Not allowed api」エラーの原因はスコープ不足ではない**。`file`/`file.read`スコープを正しく要求してもService Account認証では共有ドライブAPIが403になる。スコープが本当に不足している場合は別のエラーメッセージ(`has not permission api scope`)が返ることを、`board`スコープで意図的に間違えた検証で確認済み。
|
||||
- 新しいService Accountを発行して再検証しても同じ結果(403 `Not allowed api`)だった → アカウント固有の設定漏れではなく、**Service Account認証という方式自体の制約**と判断。
|
||||
- 根拠ドキュメント: https://developers.worksmobile.com/jp/docs/drive に「Drive API は、User Account 認証で取得した Access Token で利用できます。Service Account 認証 (JWT) で取得した Token では利用できません。」と明記(マイドライブ・共有ドライブ・組織/グループフォルダ全てに適用される全体制約として記載)。
|
||||
- 掲示板(board)APIは引き続きService Account認証で問題なく動作する(`get-token.js` で確認済み)。今回の制約はDrive/共有ドライブ系APIのみ。
|
||||
|
||||
## ファイル構成の要点
|
||||
|
||||
| ファイル | 役割 |
|
||||
|---|---|
|
||||
| `backup.js` | メインのバックアップ実行エントリポイント。`run`/`create-test-db`/`drop-test-db` サブコマンド |
|
||||
| `lineworks-authorize.js` | User Account認証の**一回限り**のセットアップ(対話式、人間の操作が必須) |
|
||||
| `get-token.js` | Service Account認証の動作確認用CLI(掲示板等、Drive以外の用途) |
|
||||
| `lib/lineworksUserAuth.js` | backup.jsが使うUser Account認証(OAuth)。refresh_token管理 |
|
||||
| `lib/lineworksAuth.js` | get-token.jsが使うService Account認証(JWT) |
|
||||
| `lib/lineworksDrive.js` | 共有ドライブAPI呼び出しラッパー(フォルダ作成・アップロード・削除・一覧) |
|
||||
| `lib/pgDump.js` | pg_dump実行・分割書き出し |
|
||||
| `lib/env.js` | `.env`読み込み(常にファイルが正)・書き戻し |
|
||||
| `lib/logger.js` | 月次ローテーションするログ(INFO/WARN/ERROR) |
|
||||
| `lib/testDb.js` | テスト用DB作成・削除 |
|
||||
| `SPEC.md` | 詳細設計書(全体像・APIエンドポイント一覧・処理フロー) |
|
||||
| `sftp.sh` | **旧手段。使用しない・参照しない**(ユーザー指定) |
|
||||
|
||||
## 未解決・要判断事項
|
||||
|
||||
- `LW_REDIRECT_URI` を `https://example.com/callback`(プレースホルダー)のまま使うか、実際に管理下のURLに変更するか未確定。プレースホルダーのままでも動作上は問題ない(ブラウザのアドレスバーからcodeを手動コピーする運用のため)が、気になる場合はDeveloper Console側の登録値と `.env` を揃えて変更可能。
|
||||
- cron等への自動実行登録はまだ未着手(今回のスコープ外、動作確認後の話)。
|
||||
- `BACKUP_RETENTION_COUNT`(既定100)や `PG_DUMP_SPLIT_SIZE_MB`(既定3072)は初期値のままなので、必要に応じてサーバー側で調整。
|
||||
354
dbbackup/SPEC.md
Normal file
354
dbbackup/SPEC.md
Normal file
@ -0,0 +1,354 @@
|
||||
# PostgreSQL → LINE WORKS 共有ドライブ 自動バックアップ (Node.js)
|
||||
|
||||
## 実装・デプロイ状況(2026-07-11時点)
|
||||
|
||||
- 実装完了・`/opt/dbbackup/` に配置済み(本ドキュメント内の`dbbackup/`という記載は配置先ルートを指す)。
|
||||
- Node.js v22.23.1(dnfのAppStreamリポジトリから導入。要件`>=18.13.0`は満たす。追加npm依存なし)。
|
||||
- User Account認証(`kenichiro.nogi@next-hd.co.jp`)によるセットアップ完了、`refresh_token`取得・保存済み。
|
||||
- `PGPASSWORD`はPleasanter本体の設定ファイル(`/web/pleasanter/Implem.Pleasanter/App_Data/Parameters/Rds.json`の`SaConnectionString`内`PWD=`)から転記して設定済み。
|
||||
- `create-test-db` → `run --database=...` → `drop-test-db` によるエンドツーエンド検証済み(pg_dump、共有ドライブへのフォルダ作成・アップロード、ローテーション、ログアップロードすべて成功を確認)。
|
||||
- 本番`PGDATABASE=Implem.Pleasanter`に対する実行はまだ未実施(検証はテスト用DBのみ)。cronなどへの自動実行登録も未着手。
|
||||
- サーバーホスト名(このサーバーでは`nextoffice2`)をバックアップファイル名の先頭に`[nextoffice2]`として自動付与する機能を追加済み(詳細は後述「複数サーバーでの運用について」)。
|
||||
|
||||
## Context
|
||||
|
||||
現行の `backup.sh` は `pg_dump` でローカルにダンプを作成するところまでは動いているが、転送処理(`sftp.sh` 経由)はコメントアウトされて使われていない。転送先を独自VPN上のSFTPサーバーから **LINE WORKS ドライブの共有ドライブ**に変更したいというのが今回の要望。
|
||||
|
||||
**認証方式について**: 当初 `get-token.js` / 姉妹プロジェクトの `lineworks-anythingllm.js`(LINE WORKS 掲示板連携)と同じ「Service AccountのJWT自己署名 → OAuth2トークン取得」を共有ドライブAPIにも流用する想定だったが、実装・検証の結果、**Drive/共有ドライブAPIはService Account認証(JWT)に対応しておらず、User Account認証(OAuth 2.0 認可コードフロー)が必須**であることが判明した(公式ドキュメント `https://developers.worksmobile.com/jp/docs/drive` に明記、かつ実機検証でも「スコープ不足」とは異なるエラー`Not allowed api`を確認)。そのため:
|
||||
- 掲示板API等、既存のService Account認証で動くもの → 従来通り `get-token.js` / `lib/lineworksAuth.js`
|
||||
- Drive/共有ドライブAPI(backup.jsが使うもの) → 新設の `lib/lineworksUserAuth.js`(User Account認証、OAuth認可コードフロー)
|
||||
|
||||
を使い分ける。User Account認証は実ユーザー(kenichiro.nogi@next-hd.co.jp)による一度きりのブラウザ認可が必要なため、`lineworks-authorize.js` という一回限りのセットアップスクリプトを用意し、そこで取得した `refresh_token` を `.env` に保存、以降 `backup.js` は無人で `refresh_token` から `access_token` を再発行する。
|
||||
|
||||
保存先は 共有ドライブ「システムバックアップ」 > `Pleasanter_DB_Backup` > 日付フォルダ(`YYYYMMDD`) で、フォルダが無ければ都度作成。ファイル名側に秒単位まで含むタイムスタンプを付与し、同日複数回実行しても上書きされないようにする。アップロード後は日付フォルダの個数を数え、保持回数(既定100・`.env`で変更可)を超えた古いフォルダをLINE WORKS側から削除してローテーションする。
|
||||
|
||||
さらに、バックアップ開始・終了、アップロード開始・終了、LINE WORKSログイン(トークン取得)からディレクトリ取得・作成までの各過程を秒単位のタイムスタンプ付きで詳細ログに記録する。ログは同じ共有ドライブの `Pleasanter_DB_Backup/logs/` 配下に月1回ローテーション(月ごとに1ファイル)で保存し、実行のたびに上書きアップロードする。通常の経過はINFO、リトライ等の異常系はWARN/ERRORで記録する。
|
||||
|
||||
`sftp.sh`(旧転送手段)および旧 `backup.sh`(node版移行前のシェル版バックアップ)は、node版(`backup.js`)への移行が完了し不要となったため、2026-07-11に削除した。`.env`内の旧backup.sh専用変数(`DATE`/`REMOTEDIR`/`SAVEPATH`/`PREFIX`/`EXT`/`FNAME`/`DBNAME`/`PASSWORD`。特に平文パスワードを含んでいた)も同時に削除済み。
|
||||
|
||||
任意にパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を試せるよう、本番DBに触れずに使えるテスト用DBの作成・削除コマンドも `backup.js` のサブコマンドとして用意する。
|
||||
|
||||
## 参照した公式ドキュメント(エンドポイント確定分)
|
||||
|
||||
| 操作 | Method | URL |
|
||||
|---|---|---|
|
||||
| 共有ドライブ一覧 | GET | `/v1.0/sharedrives` → `{ sharedrives: [{ sharedriveId, name, ... }] }` |
|
||||
| ルート直下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files` → `{ files: [...], responseMetaData: { nextCursor } }` |
|
||||
| フォルダ配下 一覧 | GET | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/children` (同形式) |
|
||||
| ルート直下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/createfolder` body `{ fileName }` → 201 `{ fileId, fileType:"FOLDER", ... }` |
|
||||
| フォルダ配下 フォルダ作成 | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}/createfolder` body `{ fileName }` |
|
||||
| アップロードURL発行(段階1) | POST | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` body `{ fileName, fileSize, modifiedTime, overwrite, resume, suffixOnDuplicate }` → `{ uploadUrl, offset }` |
|
||||
| 実バイト送信(段階2) | POST | `uploadUrl` (別ホスト `apis-storage.worksmobile.com`) — `multipart/form-data`, フィールド名 `Filedata`, `Authorization: Bearer` 同梱 → 201 `{ fileId, fileName, fileSize }` |
|
||||
| 削除(ファイル/フォルダ) | DELETE | `/v1.0/sharedrives/{sharedriveId}/files/{fileId}` → 204 |
|
||||
|
||||
必要スコープ: `file`(読み書き全般)。`.env` の `LW_SCOPE` は `get-token.js`(Service Account認証)専用に残し、バックアップ用(User Account認証)には別変数 `LW_SCOPE_DRIVE` を新設して混同しないようにする。
|
||||
|
||||
### 認可コードフロー / リフレッシュ関連エンドポイント
|
||||
|
||||
| 操作 | Method | URL |
|
||||
|---|---|---|
|
||||
| 認可リクエスト | GET(ブラウザ) | `https://auth.worksmobile.com/oauth2/v2.0/authorize?client_id=...&redirect_uri=...&response_type=code&scope=file&state=...` |
|
||||
| 認可コード→トークン交換 | POST | `https://auth.worksmobile.com/oauth2/v2.0/token` (`grant_type=authorization_code`) |
|
||||
| リフレッシュ | POST | 同上 (`grant_type=refresh_token`) |
|
||||
|
||||
Refresh Token Rotation が ON のため、リフレッシュのたびに新しい `refresh_token` が発行され古い方は無効化される。取得の都度 `.env` の `LW_USER_REFRESH_TOKEN` を書き換えて保存する。
|
||||
|
||||
## 新規ファイル構成 (dbbackup/ 配下)
|
||||
|
||||
```
|
||||
dbbackup/
|
||||
backup.js … エントリポイント (node backup.js)
|
||||
lineworks-authorize.js … User Account認証の一回限りのセットアップ(ブラウザ認可→refresh_token取得・.env保存)
|
||||
lib/
|
||||
env.js … .env の読み込み(process.envへ反映。.envが常に正、実行環境変数より優先)・LW_SHAREDRIVE_ID/LW_USER_REFRESH_TOKEN書き戻し
|
||||
logger.js … 月次ローテーションするローカルログファイルへの書き込み(INFO/WARN/ERROR、時分秒付き)
|
||||
lineworksAuth.js … get-token.js が使うService Account認証(JWT)。コード内にデフォルト値は持たず、.env未設定なら例外
|
||||
lineworksUserAuth.js … backup.jsが使うUser Account認証(OAuth認可コードフロー)。認可URL組立て・コード交換・refresh_tokenによる再発行(rotation対応)
|
||||
lineworksDrive.js … 共有ドライブAPI一覧のラッパー(共有ドライブID解決・フォルダ確保・アップロード・一覧・削除)、429リトライ付きfetch。logger連携で各API呼び出しをINFO/WARNログに記録
|
||||
pgDump.js … pg_dump をspawnし、標準出力を3072MB単位でローカルファイルに分割書き出し
|
||||
testDb.js … createdb/dropdb をspawnしてテスト用DBを作成・削除するヘルパー
|
||||
logs/ … ログのローカル置き場(月次ファイル、既定 backup-YYYYMM.log)
|
||||
package.json … 依存ゼロ(fetch/FormData/Blob/fs.openAsBlob等 Node標準のみ)。engines: node>=18.13
|
||||
get-token.js … Service Account認証の動作確認用CLI。内部実装は lib/lineworksAuth.js を呼ぶ薄いラッパー。コード内にデフォルト値を持たず、必要な環境変数が.envに無ければ実行不可
|
||||
.env … 下記キーを追記
|
||||
SPEC.md … 本仕様書(このplanの内容)をそのまま保存。実装の最初のステップとしてコミットする
|
||||
```
|
||||
|
||||
`sftp.sh`・旧`backup.sh`は2026-07-11に削除済み(前述)。
|
||||
|
||||
### `.env` 追記内容
|
||||
|
||||
```
|
||||
# --- Drive(共有ドライブ)バックアップ関連 (User Account認証) ---
|
||||
LW_SCOPE_DRIVE=file
|
||||
LW_REDIRECT_URI=https://example.com/callback
|
||||
LW_USER_REFRESH_TOKEN=
|
||||
LW_SHAREDRIVE_NAME=システムバックアップ
|
||||
LW_SHAREDRIVE_ID=
|
||||
LW_BACKUP_ROOT_FOLDER_NAME=Pleasanter_DB_Backup
|
||||
|
||||
# --- PostgreSQL接続 ---
|
||||
PG_DUMP_BIN=/usr/bin/pg_dump
|
||||
PGHOST=localhost
|
||||
PGPORT=5432
|
||||
PGUSER=postgres
|
||||
PGPASSWORD=
|
||||
PGDATABASE=Implem.Pleasanter
|
||||
|
||||
# --- ローカル一時ファイル・分割・ローテーション ---
|
||||
BACKUP_LOCAL_DIR=/var/lib/pgsql/backup/dmp/
|
||||
PG_DUMP_SPLIT_SIZE_MB=3072
|
||||
BACKUP_RETENTION_COUNT=100
|
||||
|
||||
# --- ログ ---
|
||||
LOG_LOCAL_DIR=./logs
|
||||
LW_LOG_FOLDER_NAME=logs
|
||||
|
||||
# --- テスト用DB(create-test-db / drop-test-db) ---
|
||||
PG_CREATEDB_BIN=createdb
|
||||
PG_DROPDB_BIN=dropdb
|
||||
TEST_DB_NAME=pleasanter_backup_test
|
||||
```
|
||||
|
||||
`LW_SHAREDRIVE_ID` は初回実行時に名前(`LW_SHAREDRIVE_NAME`)から解決し、`lib/env.js` が `.env` ファイルの当該行を書き換えて永続化する。2回目以降はID解決APIを呼ばずキャッシュ値を使う。
|
||||
|
||||
## 処理フロー (`backup.js`)
|
||||
|
||||
全体を `try { ... } catch { ...ERRORログ... } finally { ...ログファイルのアップロード... }` で包む。`accessToken` / `sharedriveId` / `backupFolderId` / `logsFolderId` は関数スコープの変数に保持し、`finally` 内でログアップロード時に(まだ未取得なら)再利用・再取得する。
|
||||
|
||||
1. `.env` 読み込み(`lib/env.js`、`process.env` に無いキーだけ反映=実環境変数を優先)。`logger.info("===== バックアップ開始 =====")`。
|
||||
2. `lib/pgDump.js`: `logger.info("pg_dump開始: host=... db=...")` → `PG_DUMP_BIN -h $PGHOST -p $PGPORT -U $PGUSER -Fc $PGDATABASE` を spawn(`PGPASSWORD` 設定時のみ子プロセスのenvに渡す)。標準出力を `BACKUP_LOCAL_DIR` 配下に `[<os.hostname()>]postgres-<YYYYMMDD-HHMMSS>.dmp000`, `.dmp001`, … として `PG_DUMP_SPLIT_SIZE_MB` ごとに書き出す(`split -d -a 3` と同じ3桁ゼロ埋め連番)。ホスト名プレフィックスは`backup.js`側で`os.hostname()`から取得して`prefix`引数として渡す(`.env`等の追加設定は不要、サーバーごとに自動判別)。完了時 `logger.info("pg_dump終了: 合計サイズ=... 所要時間=...")`。exit code非0またはstderrで失敗時は `logger.error(...)` を出して例外を投げここで中断(アップロード・ローテーションは行わない)。
|
||||
3. `logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)")` → `lib/lineworksUserAuth.js` の `getUserAccessToken()` で `LW_USER_REFRESH_TOKEN` から `access_token` を再発行(rotationで返る新しい`refresh_token`は即座に`.env`へ保存) → 成功で `logger.info("アクセストークン取得成功")`、失敗で `logger.error(...)`(`LW_USER_REFRESH_TOKEN`未設定時は「先に`lineworks-authorize.js`を実行してください」という内容の例外)。
|
||||
4. `lib/lineworksDrive.js`(各呼び出し前後でINFOログ、429リトライはWARNログを`logger`経由で出力):
|
||||
- `resolveSharedDriveId()`: `.env` の `LW_SHAREDRIVE_ID` があれば `logger.info("共有ドライブID: キャッシュ値を使用 (...)")`。無ければ一覧取得→`LW_SHAREDRIVE_NAME`と一致するものを検索→`.env`へ書き戻し、`logger.info("共有ドライブID解決: name=... id=...")`。見つからなければ`logger.error`してエラー終了。
|
||||
- `ensureFolder(parentFileId, name)`: 一覧を見て同名の`FOLDER`があれば `logger.info("フォルダ確認: 既存を使用 name=... id=...")`、無ければ作成し `logger.info("フォルダ確認: 新規作成 name=... id=...")`。これを `Pleasanter_DB_Backup`(ルート直下)→ 日付フォルダ`YYYYMMDD`(その配下)の2階層分呼ぶ。
|
||||
- 分割されたダンプファイルを順番に `uploadFile(sharedriveId, dateFolderId, filePath)` でアップロード。各ファイルについて `logger.info("アップロード開始: ファイル名=... サイズ=...")` → 段階1でuploadUrl取得→段階2でPOST → 成功で `logger.info("アップロード成功: ファイル名=... fileId=... 所要時間=...")`、失敗で `logger.error(...)` して例外を投げ中断(ローカルファイルは削除しない=再実行できるように残す)。大きいファイルは `fs.openAsBlob()` でメモリに全展開せず送信。
|
||||
5. 全パートのアップロードに成功したら `BACKUP_LOCAL_DIR` 上の当該一時ファイルを削除し `logger.info("ローカル一時ファイル削除完了")`。
|
||||
6. ローテーション: `logger.info("ローテーション開始 (保持件数=...)")` → `Pleasanter_DB_Backup` 配下のフォルダ一覧を取得し、フォルダ名(=日付文字列なので辞書順=時系列順)でソート。`BACKUP_RETENTION_COUNT` を超えた古い方から `deleteFile(sharedriveId, fileId)` で削除しつつ `logger.info("削除: フォルダ名=... fileId=...")`。`logger.info("ローテーション終了: 削除件数=...")`。
|
||||
7. `logger.info("===== バックアップ終了(成功) ===== 所要時間=...")`。失敗時は catch 節で `logger.error("===== バックアップ終了(失敗): ...")` を出し `process.exitCode = 1`。
|
||||
8. `finally` 節: `logger.info("ログファイルアップロード開始")` → 4.で解決済みの `sharedriveId` を使って(未解決なら改めて解決を試み、それも失敗したらログアップロードは諦めローカルログのみ残す)`Pleasanter_DB_Backup/logs` フォルダを確保し、当月ログファイル(`backup-YYYYMM.log`)を `overwrite: true` でアップロード(段階1リクエストの`overwrite`フラグをtrueにして同名ファイルを上書き)。成功/失敗いずれも最後にコンソールへも出力。
|
||||
|
||||
## 環境設定ファイルバックアップ(2026-07-11追加)
|
||||
|
||||
DBのみでは「サーバーが完全に失われた場合に同じ環境を再構築できない」ため、DB以外の設定ファイル一式もbackup.js実行のたびに合わせてバックアップする機能を追加。
|
||||
|
||||
**対象(`.env`の`CONFIG_BACKUP_PATHS`、カンマ区切りで変更可能)**
|
||||
|
||||
| パス | 理由 |
|
||||
|---|---|
|
||||
| `/etc/nginx` | リバースプロキシ設定一式 |
|
||||
| `/web/pleasanter/Implem.Pleasanter/App_Data/Parameters` | Pleasanterの環境固有設定(ユーザー指定) |
|
||||
| `/web/pleasanter/Implem.Pleasanter/wwwroot` | 静的ファイル・添付ファイル等(ユーザー指定) |
|
||||
| `/web/pleasanter/Implem.CodeDefiner/Implem.License.dll` | ライセンスファイル、再ダウンロード不可(ユーザー指定) |
|
||||
| `/web/pleasanter/Implem.Pleasanter/Implem.License.dll` | 同上(ユーザー指定) |
|
||||
| `/etc/letsencrypt` | HTTPS証明書一式(調査の上追加。無いと証明書の再発行が必要になる) |
|
||||
| `/etc/systemd/system/pleasanter.service` | Pleasanterを起動するための自作systemdユニット(調査の上追加。パッケージ由来ではないため他に控えが無い) |
|
||||
| `/var/lib/pgsql/18/data/postgresql.conf` | PostgreSQLサーバー設定(調査の上追加。pg_dumpはデータのみでサーバー設定は含まない) |
|
||||
| `/var/lib/pgsql/18/data/pg_hba.conf` | PostgreSQL接続認証設定(同上) |
|
||||
|
||||
`/web/pleasanter/Implem.Pleasanter/`配下の実行バイナリ本体(dotnet依存関係含め434MB)は、Pleasanter公式配布物から再構築可能なため対象外とした(ユーザー指定の対象からも除外されていた)。
|
||||
|
||||
**検討したが対象外としたもの**
|
||||
- `/opt/dbbackup`自体(このバックアップ/リストアの仕組み一式): `.env`にLINE WORKSのクライアントシークレットやDBパスワード、秘密鍵ファイルが含まれるため、**それらの認証情報をアップロード先であるLINE WORKS共有ドライブ自身に保存することはセキュリティ上推奨しない**と判断し、あえて含めなかった。この一式は別途安全な方法(パスワード管理ツール、社内の別のセキュアな保管場所等)で保管することを推奨。
|
||||
- firewalldの設定: 確認したところ本サーバーではfirewalld自体が起動していないため対象外。
|
||||
- cronの設定: 2026-07-11時点でまだcron未登録のため対象なし。今後cron登録した場合は`/etc/cron.d/dbbackup`等を追加で対象に含めることを推奨。
|
||||
- SSHホスト鍵: 新サーバーでは通常再生成すれば足りるため対象外。
|
||||
|
||||
**処理内容(`lib/configBackup.js`、`backup.js`から呼び出し)**
|
||||
- 対象パスのうち実在するものだけを`tar -czf`で1つの`.tar.gz`にまとめる(存在しないパスは警告ログのみでスキップ)。
|
||||
- ファイル名は`[hostname]config-<timestamp>.tar.gz`(DBダンプと同じ日付フォルダへアップロード。ローテーション対象にも自動的に含まれる)。
|
||||
- DBバックアップとは独立した`try/catch`で実行するため、**設定ファイルバックアップが失敗してもDBバックアップ自体の成否には影響しない**(エラーはログに記録されるのみ)。
|
||||
- `--database`指定時(テスト実行)でもスキップせず毎回実行する(読み取り専用でサーバーへの影響が無いため)。
|
||||
|
||||
**動作検証(2026-07-11)**
|
||||
- テスト用DBでのバックアップ実行時に、9件すべてのパスが検出されアーカイブ化・アップロード成功を確認(約21.7MB)。
|
||||
- 手動で同じロジックを実行し、tar内に9件すべてのパスが正しく含まれていることを確認済み。
|
||||
|
||||
## cron登録コマンド(未登録・参考用、2026-07-11時点)
|
||||
|
||||
運用方針: 週1回、毎週水曜日の午前2時にバックアップを実行する。**現時点ではまだcron登録は行っていない**。実際に登録する際は以下のいずれかの方法で設定する。
|
||||
|
||||
**方法1: `/etc/cron.d/dbbackup` を新規作成する場合**
|
||||
|
||||
```
|
||||
0 2 * * 3 root cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1
|
||||
```
|
||||
|
||||
**方法2: `crontab -e`(rootのcrontab)に追記する場合**
|
||||
|
||||
```
|
||||
0 2 * * 3 cd /opt/dbbackup && /usr/bin/node backup.js run >> /opt/dbbackup/logs/cron-stdout.log 2>&1
|
||||
```
|
||||
|
||||
- `0 2 * * 3`: 分=0, 時=2, 日=任意, 月=任意, 曜日=3(水曜日。cronの曜日は0=日曜始まり)
|
||||
- `node`のパスは`/usr/bin/node`(本サーバーへの`dnf install nodejs`によるインストール先)。
|
||||
- `backup.js`自体が`lib/logger.js`でのローカルログ記録・LINE WORKS共有ドライブへのログアップロードを行うため、`cron-stdout.log`へのリダイレクトは「nodeプロセス自体が起動できなかった」等の想定外の異常のみを拾うための保険。通常の実行結果は共有ドライブの`Pleasanter_DB_Backup/logs/backup-<年月>.log`、およびローカルの`/opt/dbbackup/logs/backup-<年月>.log`を参照すること。
|
||||
- 登録前に`logs`ディレクトリの書き込み権限があることを確認(`/opt/dbbackup/logs`はrootで作成される想定)。
|
||||
|
||||
## リストア機能 (`restore.js`, 2026-07-11追加)
|
||||
|
||||
`node restore.js [--database=<dbName>] [--yes]` で実行する、共有ドライブからのデータベース復元機能。実装・テストDBでの検証済み。
|
||||
|
||||
**処理内容**
|
||||
1. このサーバーのホスト名(`os.hostname()`、例: `nextoffice2`)に一致する`[hostname]postgres-...`形式の最新バックアップを、共有ドライブの`Pleasanter_DB_Backup`配下から特定する(`lib/backupLocator.js`)。日付フォルダを新しい順に探索し、対象ホスト名のファイルが無いフォルダは遡ってスキップする(複数サーバー運用でその日はまだ対象サーバーの分が無いケースに対応)。同一タイムスタンプの分割ファイル(`.dmp000`, `.dmp001`, …)をすべて特定する。
|
||||
2. 対話確認: 復元元・復元先DB名・Webサービス停止有無を表示し、復元先DB名そのものを入力させて一致した場合のみ続行(`--yes`指定時はスキップ、検証・自動化用途)。
|
||||
3. 特定したバックアップの各パートをダウンロードし(`lib/lineworksDrive.js`の`downloadFile()`。`/download`エンドポイントの302リダイレクト先URLも含め、両方のリクエストに`Authorization: Bearer`ヘッダーが必要 — 片方だけだと401になることを実機で確認済み)、パート順に結合して1つの`-Fc`形式ダンプファイルに復元する(分割は単純なバイト分割のため、結合するだけで元のpg_dump出力に戻る)。
|
||||
4. **Webサービス停止**(`lib/serviceControl.js`、既定`pleasanter`、`.env`の`WEB_SERVICE_NAME`で変更可): DBへの接続を切るため、ドロップ前に停止する。**`--database`でDB名を上書きした場合(テスト用DB等)は、本番サービスを巻き込まないよう停止処理自体をスキップする**(`--database`未指定=本番DB復元の場合のみ実施)。停止前に稼働中か確認し、既に停止していた場合は「対象外」として記録するのみで、完了後の自動再起動も行わない(=元の状態を変えない)。
|
||||
5. **復元対象をダウンロードし終えた後で**、現在のデータベースを`[hostname]pre-restore-<timestamp>`という別名(通常の`postgres-`とは異なるプレフィックス)でpg_dump・共有ドライブへアップロードする。この順序(ダウンロード→現状バックアップ)を守ることで、この安全バックアップ自体が次回の「最新バックアップ検索」に混入しない(`postgres-`プレフィックスではないため`lib/backupLocator.js`の検索対象外)。
|
||||
6. データベースをドロップ(`--if-exists`なし。対象DBが実在することを前提とし、無い場合はエラーで中断)→同名で再作成。
|
||||
7. 結合済みのダンプファイルから`pg_restore -h ... -U ... -d <dbName> --no-owner <dumpfile>`で復元。
|
||||
8. ローカル一時ファイル(ダウンロード分)を削除。**Webサービスを再起動**(4.で実際に停止していた場合のみ。成功・失敗いずれの場合も`finally`節で実行し、再起動自体が失敗した場合は手動での`systemctl start`実行を促すエラーログを出す)。成否に関わらず`lib/logUpload.js`(backup.jsと共用)で当月ログを共有ドライブへアップロード。
|
||||
|
||||
**新規/変更ファイル**
|
||||
- `restore.js` … エントリポイント
|
||||
- `lib/backupLocator.js` … 最新バックアップ特定ロジック
|
||||
- `lib/pgRestore.js` … `pg_restore`のspawnラッパー
|
||||
- `lib/serviceControl.js` … `systemctl is-active/stop/start`のラッパー(Webサービスの安全な停止・再起動用)
|
||||
- `lib/lineworksDrive.js` … `downloadFile()`を追加
|
||||
- `lib/testDb.js` … 汎用の`createDatabase()`/`dropDatabase()`を追加(既存の`createTestDb`/`dropTestDb`はそのまま維持)
|
||||
- `lib/pgDump.js` … `formatDateFolderName()`を追加(backup.js/restore.js共用)
|
||||
- `lib/logUpload.js` … backup.js内にあった`uploadLogFile()`を切り出し、backup.js/restore.js共用に変更
|
||||
- `.env` … `PG_RESTORE_BIN`, `WEB_SERVICE_NAME`(既定`pleasanter`)を追加
|
||||
|
||||
**動作検証(2026-07-11、テストDBにて実施。本番DBには一切触れていない)**
|
||||
- テスト用DBにマーカー行を入れてバックアップ→内容を変更→`node restore.js --database=pleasanter_backup_test --yes`を実行→復元後にバックアップ時点の値が正しく戻っていることを2回確認(=最新バックアップからの復元であり、直前の状態ではないことを確認)。
|
||||
- 確認プロンプトで誤った入力をした場合、ドロップされずに中断されることを確認。
|
||||
- ダウンロードAPIの2段階目(実際のファイル取得URL)でも`Authorization`ヘッダーが必須であること(無いと401)を実機で確認済み。
|
||||
- **`--database`指定時は`pleasanter.service`の状態が実行前後で一切変化しない**ことを確認(本番サービスを巻き込まない設計の検証)。
|
||||
- `lib/serviceControl.js`の`isServiceActive`/`stopService`/`startService`自体は、systemdのtransientユニット(`systemd-run --collect`、実行後は自動的に消える使い捨てユニットで本番には一切影響しない)を使って個別に動作検証済み(起動確認→停止→停止後にis-active=false→存在しないユニットへのstart失敗時のエラーメッセージも確認)。
|
||||
|
||||
**未検証・注意事項**
|
||||
- 本番DB(`Implem.Pleasanter`)に対する`--database`未指定でのフルリストア(実際のサービス停止・再起動を伴う本番実行)はまだ行っていない。
|
||||
- pg_restoreは`--no-owner`のみ付与(`--clean`等は付けていない。ドロップ→再作成した空DBへの復元のため不要と判断)。
|
||||
- テスト中に本番バックアップの「最新」ポインタが一時的にテストDBのものに上書きされたため、検証後に本番DB(`Implem.Pleasanter`)への`node backup.js run`を再実行し、最新バックアップが本番データに戻っていることを確認済み(2026-07-11 12:29)。**`--database`でテスト実行した直後は、本番の`node backup.js run`を必ず1回実行してから運用に戻すこと**(そうしないと`--database`無指定のrestore.jsが誤ってテストDBのデータを最新として拾ってしまう)。
|
||||
|
||||
## 設定ファイルリストア機能 (`restore.js config`, 2026-07-11追加)
|
||||
|
||||
DB以外の環境設定ファイル(前述「環境設定ファイルバックアップ」参照)を復元するための機能。**DBリストア(`restore.js database`)とは独立した別コマンド**として実装した(`node restore.js config [--yes]`)。**Pleasanter自体は既にインストール済みであることが前提**(dotnet本体・パッケージ一式434MBは対象外、Parameters/wwwroot/ライセンスDLL等の設定・データ部分のみが対象)。
|
||||
|
||||
**処理内容**
|
||||
1. このホスト名に一致する最新の設定アーカイブ(`[hostname]config-<timestamp>.tar.gz`)を`lib/backupLocator.js`の`findLatestConfigArchive()`で特定・ダウンロード。
|
||||
2. 対話確認: 復元元・復元先パス一覧・退避方式・停止対象サービスを表示し、**ホスト名そのものを入力させて**一致した場合のみ続行(`--yes`でスキップ可)。
|
||||
3. **`.env`の`CONFIG_RESTORE_SERVICES`(既定`nginx,postgresql-18,pleasanter`)に列挙された全サービスを、個々のパスとの対応関係を考慮せず一律に事前停止する**(ユーザー指示による。DBリストアと異なり「一部だけ止める」判定はしない)。
|
||||
4. `CONFIG_BACKUP_PATHS`の各パスについて、**復元先に既存のファイル/ディレクトリがあれば削除せず、必ず`<path>.pre-restore-<timestamp>`にリネームして保持してから**、アーカイブ側の内容を配置する(`lib/configRestore.js`の`restoreConfigPaths()`)。アーカイブ側にそのパスが存在しない場合は警告ログのみでスキップ(退避も配置も行わない)。
|
||||
5. **サービスの自動再起動は行わない**(ユーザー指示による)。3.で実際に停止したサービスの一覧をログ・コンソールに表示し、手動で実行すべき`systemctl start ...`コマンド(nginxのみ`nginx -t && systemctl start nginx`)を案内する。
|
||||
6. 成否に関わらず`lib/logUpload.js`で当月ログを共有ドライブへアップロード。
|
||||
|
||||
**新規ファイル**
|
||||
- `lib/configRestore.js` … tar展開(`extractArchive`)、退避+配置(`restoreConfigPaths`)
|
||||
- `lib/backupLocator.js` … `findLatestConfigArchive()`を追加(既存の`findLatestBackupFileSet`とは別関数。設定アーカイブは分割されない単一ファイルのため)
|
||||
- `.env` … `CONFIG_RESTORE_SERVICES`を追加
|
||||
|
||||
**動作検証(2026-07-11)**
|
||||
- サンドボックスでの事前検証: `extractArchive`/`restoreConfigPaths`のロジックのみを検証(バックアップ時点の内容で固めたアーカイブに対し、現在の内容を意図的に変更(ドリフトを模擬)した状態から復元し、復元先が正しく戻ること・退避ファイルにドリフト後の内容が保持されること・アーカイブ非含有パスは無変更であることを確認)。
|
||||
|
||||
**本番環境での実行・インシデントと修正(2026-07-11)**
|
||||
|
||||
ユーザーの指示により、本番の`nginx`/`postgresql-18`/`pleasanter`を実際に停止した上で`node restore.js config`をフル実行(確認プロンプトにホスト名を入力して実施)。結果:
|
||||
|
||||
- 9パス中8パス(nginx設定・Parameters・wwwroot・ライセンスDLL×2・systemdユニット・postgresql.conf・pg_hba.conf)は正常に復元完了。
|
||||
- **`/etc/letsencrypt`のみ、nginxが起動失敗**。原因は、`live/*.pem`が持つ相対シンボリックリンク(例: `fullchain.pem -> ../../archive/<domain>/fullchain1.pem`、certbotの標準構成)を配置する際に使っていた`fs.cpSync(source, dest, {recursive:true})`が、**相対リンクをコピー元ディレクトリ(tar展開用の一時ディレクトリ)を基準とした絶対パスに書き換えてしまう**という挙動を持っていたため(Node.js の既知の挙動。サンドボックスで再現・確認済み)。tar自体(`extractArchive`)はシンボリックリンクを正しく相対パスのまま保存・展開できていた。
|
||||
- **復旧**: 退避してあった`/etc/letsencrypt.pre-restore-<timestamp>`(正常な状態)を`/etc/letsencrypt`にスワップして即座に復旧 → `postgresql-18`→`pleasanter`→`nginx`の順で起動 → HTTP/HTTPS双方の応答・DBテーブル数(93件)を確認し、正常復旧を確認済み。
|
||||
- **恒久対応**: `lib/configRestore.js`の配置処理を、シンボリックリンクをそのまま複製する`cp -a`(spawn経由)に変更(`restoreConfigPaths`は非同期関数に変更)。修正後、サンドボックスで同様の相対シンボリックリンク構成を再現し、復元後もリンクが`../archive/cert1.pem`のような相対パスのまま正しく保持されることを再検証済み。
|
||||
- 本番環境での再実行によるフル(サービス停止込み)なエンドツーエンド再検証は、サンドボックス検証で十分と判断し実施していない(ユーザー確認の上で見送り)。
|
||||
- インシデント調査用に一時的に残していた壊れた`/etc/letsencrypt.broken-cpsync-symlink-bug`は削除済み。設計通りの退避ファイル(`*.pre-restore-20260711-132523`、7件)はそのまま保持している。
|
||||
|
||||
**教訓・今後の注意点**
|
||||
- 対象パスにシンボリックリンクを含む可能性がある場合(今回は`/etc/letsencrypt`)、Node.jsの`fs.cpSync`/`fs.cp`の`recursive`コピーは要注意。シンボリックリンクを正確に複製する必要がある処理は`cp -a`等のOSコマンドを使うこと。
|
||||
- 今後`CONFIG_BACKUP_PATHS`に新しいパスを追加する際は、そのパス配下にシンボリックリンクが無いか(`find <path> -type l`)を事前に確認することが望ましい。
|
||||
|
||||
## 複数サーバーでの運用について
|
||||
|
||||
このバックアップシステムは、複数のサーバーが同一の共有ドライブ「システムバックアップ」へ並行してアップロードする運用を想定している。区別のため、ダンプファイル名先頭に`[<サーバーのホスト名>]`が自動付与される(例: `nextoffice2`というホスト名のサーバーなら`[nextoffice2]postgres-....dmp000`)。
|
||||
|
||||
**サーバーごとに個別のUser Account認証(`refresh_token`)が必要な理由**
|
||||
|
||||
- `refresh_token`はLINE WORKSアプリ(`LW_CLIENT_ID`)+ユーザーアカウント(`kenichiro.nogi@next-hd.co.jp`)に紐づくものであり、サーバー自体に紐づくものではない。そのため`.env`ごとコピーしてそのまま使うこと自体はLINE WORKS側の制約としては禁止されていない。
|
||||
- ただし本アプリはRefresh Token Rotationが有効なため、「リフレッシュのたびに新しい`refresh_token`が発行され、古い方は無効化される」(公式ドキュメント・SPEC内既述の通り)。
|
||||
- 複数のサーバーが**同一の`refresh_token`をコピーして持った状態**で、それぞれ独立して`backup.js`を実行し続けると、どちらか一方が先にローテーションした時点で、もう一方が持つ`refresh_token`は無効化済みとなり、次回実行時に認証エラーで失敗する。
|
||||
- そのため、**恒久的に並行稼働させる各サーバーは、それぞれ個別に`node lineworks-authorize.js`を実行し、サーバーごとに独立してローテーションする`refresh_token`を持たせること**。`LW_CLIENT_ID` / `LW_CLIENT_SECRET` / `LW_SHAREDRIVE_ID` はアプリ・共有ドライブ自体を指す値なので、全サーバーで共通の値をコピーして問題ない。
|
||||
- 一時的な移行(旧サーバーを廃止し新サーバーへ完全に切り替える場合など、今後`backup.js`を実行するサーバーが常に1台のみ)であれば、`.env`をそのままコピーしてよい。
|
||||
|
||||
**`refresh_token`の有効期限**
|
||||
|
||||
- `refresh_token`自体の有効期限は90日間(LINE WORKS公式ドキュメントによる)。
|
||||
- Refresh Token Rotationが有効なため、`getUserAccessToken()`実行のたびに新しい90日間有効な`refresh_token`が発行され`.env`に書き戻される。**90日以内に最低1回`backup.js`を実行し続ける限り、有効期限は自動更新され続け、再度ブラウザでの手動認証は不要**。
|
||||
- 逆に、90日以上`backup.js`を一度も実行しない期間ができると、その時点の`refresh_token`が失効し、次回実行時に認証エラーとなる。その場合は`node lineworks-authorize.js`による再認証が必要。cron等で定期実行を止めないことが実質的な前提条件。
|
||||
|
||||
## 初回セットアップ (`lineworks-authorize.js`)
|
||||
|
||||
Drive API利用に必須のUser Account認証を一度だけ行うスクリプト。
|
||||
|
||||
1. 事前にDeveloper Consoleの「User Account認証」欄のRedirect URLに `.env` の `LW_REDIRECT_URI` と完全一致する値を登録しておく。
|
||||
2. `node lineworks-authorize.js` を実行すると認可URLが表示されるので、ブラウザで開き対象アカウント(例: kenichiro.nogi@next-hd.co.jp)でログイン・同意する。**パスワードなど認証情報はこちらのスクリプトや対話には一切入力しない**(ブラウザ上のLINE WORKSログイン画面でのみ入力する)。
|
||||
3. 同意後リダイレクトされた画面のURL(またはcodeパラメータの値)をコンソールに貼り付ける。
|
||||
4. `code`を`access_token`/`refresh_token`に交換し、`refresh_token`を`.env`の`LW_USER_REFRESH_TOKEN`に保存する。
|
||||
5. 以降 `backup.js` は無人でこの`refresh_token`から`access_token`を再発行する。Refresh Token Rotationにより毎回新しい`refresh_token`が発行されるため、`lib/lineworksUserAuth.js`の`getUserAccessToken()`は取得の都度`.env`へ書き戻す。
|
||||
|
||||
## CLIサブコマンド (`backup.js`)
|
||||
|
||||
`process.argv[2]` をコマンド名として単純にディスパッチする(追加npm依存なし)。
|
||||
|
||||
| コマンド | 内容 |
|
||||
|---|---|
|
||||
| `node backup.js` または `node backup.js run [--database=<dbName>]` | 通常のバックアップフロー(上記1〜8)を実行。`--database`指定時は同プロセス内で`PGDATABASE`をその値に上書きしてから実行(`.env`は書き換えない=使い捨ての一時上書き)。 |
|
||||
| `node backup.js create-test-db [dbName]` | テスト用DBを作成(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
|
||||
| `node backup.js drop-test-db [dbName]` | テスト用DBを削除(`lib/testDb.js`)。省略時は`.env`の`TEST_DB_NAME`を使用。 |
|
||||
|
||||
これにより、本番DBに触れず以下のようなセルフテストが可能になる:
|
||||
```
|
||||
node backup.js create-test-db
|
||||
node backup.js run --database=pleasanter_backup_test
|
||||
node backup.js drop-test-db
|
||||
```
|
||||
|
||||
### `lib/testDb.js`
|
||||
|
||||
- `createTestDb(dbName)`: `PG_CREATEDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER <dbName>` を spawn(`PGPASSWORD`設定時は子プロセスのenvへ渡す)。既に存在する場合はcreatedbのエラーをそのまま表示して終了(先に`drop-test-db`を促すメッセージを添える)。
|
||||
- `dropTestDb(dbName)`: `PG_DROPDB_BIN -h $PGHOST -p $PGPORT -U $PGUSER --if-exists <dbName>` を spawn。`--if-exists`により未作成でもエラーにしない。
|
||||
- どちらも接続先ホスト/ポート/ユーザーは通常バックアップと同じ`.env`の`PGHOST`/`PGPORT`/`PGUSER`/`PGPASSWORD`を再利用する(接続先サーバーは同じ、対象DB名だけが違う)。
|
||||
- 実行結果は`console.log`/`console.error`のみ(通常バックアップの月次ログ・LINE WORKSへのログアップロードとは無関係な開発用ユーティリティのため、`logger`/Driveアップロードには連携しない)。
|
||||
|
||||
## ログ設計 (`lib/logger.js`)
|
||||
|
||||
- 出力先はローカルの `LOG_LOCAL_DIR/backup-YYYYMM.log`(月が変わったら自動的に別ファイルに切り替わる=月1回ローテーション)。プロセス内でファイルハンドルを使い回さず、書き込みの都度対象月のパスを計算して追記(`fs.appendFileSync`)することで日付またぎにも自然に対応する。
|
||||
- 1行フォーマット: `YYYY-MM-DD HH:mm:ss [LEVEL] message`(時分秒まで必須)。
|
||||
- `logger.info(msg)` / `logger.warn(msg)` / `logger.error(msg)` の3レベル。すべて `console.log`/`console.warn`/`console.error` にも同時出力し、標準出力からも経過が追えるようにする。
|
||||
- 記録対象(すべて時分秒付きINFO、異常系はWARN/ERROR):
|
||||
- バックアップ処理全体の開始・終了(成功/失敗、所要時間)
|
||||
- pg_dumpの開始・終了(失敗時はERRORでstderr内容も記録)
|
||||
- LINE WORKSアクセストークン取得(scope、開始・成功・失敗)
|
||||
- 共有ドライブID解決(キャッシュ使用/新規検索の別)
|
||||
- `Pleasanter_DB_Backup`・日付フォルダ・`logs`フォルダそれぞれの取得/作成(既存流用か新規作成か、fileId)
|
||||
- 各ダンプファイルのアップロード開始・成功・失敗
|
||||
- 429レートリミット時のリトライ(WARN、待機秒数)
|
||||
- ローカル一時ファイル削除
|
||||
- ローテーションで削除したフォルダ一覧
|
||||
- ログファイル自体のアップロード開始・成功・失敗
|
||||
- `lib/lineworksDrive.js` の各関数は `logger` を受け取って(または直接requireして)上記のログを出す。`lwFetch`内の429リトライ箇所も `console.warn` ではなく `logger.warn` に統一する。
|
||||
|
||||
## 再利用・共通化する既存ロジック
|
||||
|
||||
- JWT組立て・トークン取得: `get-token.js` / `lineworks-anythingllm.js` の `createJwtAssertion` / `getAccessToken` をほぼそのまま `lib/lineworksAuth.js` に切り出す(scopeを引数化するだけで、`get-token.js`の board向けデフォルトはCLI側に残す)。
|
||||
- 429リトライ付きfetch: `lineworks-anythingllm.js` の `lwFetch`(スロットリング+Retry-After対応)と同じ実装を `lib/lineworksDrive.js` 内に置く(ロジックはコピーだが依存関係を増やしたくないため別ファイル化はしない)。
|
||||
- ファイル名の日時フォーマットは旧`backup.sh`(2026-07-11削除)で使われていた`date '+%Y%m%d-%H%M%S'`相当をJSで再現。
|
||||
|
||||
## 動作確認方法
|
||||
|
||||
`nextoffice2`サーバーの`/opt/dbbackup`にて、以下1〜3を実施し成功を確認済み(2026-07-11)。4〜7は未実施(必要に応じて追加で実施)。
|
||||
|
||||
1. ~~`node dbbackup/get-token.js` でService Account認証~~ … 今回は未実施(Drive/バックアップ用途では使用しないため)。
|
||||
2. `node lineworks-authorize.js` を実行し、User Account認証(kenichiro.nogi@next-hd.co.jp)でrefresh_tokenが`.env`の`LW_USER_REFRESH_TOKEN`に保存されることを確認 → **OK**。
|
||||
3. `node backup.js create-test-db` でテスト用DBを作成し、`node backup.js run --database=pleasanter_backup_test`(本番`PGDATABASE`には触れない)を実行して、
|
||||
- LINE WORKSの「システムバックアップ」共有ドライブに `Pleasanter_DB_Backup/<today>/` が作成され、`[nextoffice2]postgres-....dmp000` としてダンプファイルが格納されること → **OK**
|
||||
- `Pleasanter_DB_Backup/logs/backup-<今月>.log` がアップロードされ、開始〜終了までの各ステップが時分秒付き・INFOで記録されていること → **OK**
|
||||
- `.env` に `LW_SHAREDRIVE_ID`(`@2101000001789666`)が書き込まれること → **OK**
|
||||
- ローカル `logs/backup-<今月>.log` にも同内容が残っていること → **OK**
|
||||
を確認。その後 `node backup.js drop-test-db` でテスト用DB・ローカル一時ファイルを後始末済み。
|
||||
|
||||
**以下、未実施(今後必要になった際の参考手順として残す)**
|
||||
|
||||
4. 同日にもう一度 `run --database=pleasanter_backup_test` を実行し、同じ日付フォルダ・同じ月次ログファイルが上書き(ログは追記)されること、ダンプファイル名は秒まで異なるため衝突しないことを確認。
|
||||
5. `BACKUP_RETENTION_COUNT` を小さい値(例: 2)に一時変更して連続実行し、古い日付フォルダが削除されローテーションが機能すること、ログに削除対象フォルダがINFOで記録されることを確認。
|
||||
6. 異常系: DB接続情報を意図的に間違え、アップロード・ローテーションに進まず失敗終了すること、ローカルログにERRORが記録されつつ`finally`でログファイル自体はアップロードされることを確認。
|
||||
7. 本番`PGDATABASE=Implem.Pleasanter`に対する実運用実行、およびcronへの登録(未着手)。
|
||||
206
dbbackup/backup.js
Normal file
206
dbbackup/backup.js
Normal file
@ -0,0 +1,206 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* PostgreSQL(Pleasanter) → LINE WORKS共有ドライブ 自動バックアップ
|
||||
* 詳細仕様は SPEC.md 参照。
|
||||
*
|
||||
* 使い方:
|
||||
* node backup.js … バックアップ実行 (run と同じ)
|
||||
* node backup.js run [--database=<name>] … バックアップ実行(DB名を一時的に上書き可能)
|
||||
* node backup.js create-test-db [dbName] … テスト用DB作成
|
||||
* node backup.js drop-test-db [dbName] … テスト用DB削除
|
||||
*
|
||||
* 保存先: 共有ドライブ「システムバックアップ」> Pleasanter_DB_Backup > 日付フォルダ(YYYYMMDD)
|
||||
* ログ: 同ドライブの Pleasanter_DB_Backup/logs 配下に月次ファイルとして上書きアップロード
|
||||
*/
|
||||
|
||||
require("./lib/env").loadEnv();
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const logger = require("./lib/logger");
|
||||
const { getUserAccessToken } = require("./lib/lineworksUserAuth");
|
||||
const drive = require("./lib/lineworksDrive");
|
||||
const { runPgDump, formatTimestamp, formatDateFolderName } = require("./lib/pgDump");
|
||||
const { createTestDb, dropTestDb } = require("./lib/testDb");
|
||||
const { uploadLogFile } = require("./lib/logUpload");
|
||||
const { parseConfigPaths, createConfigArchive } = require("./lib/configBackup");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const rest = argv.slice(2);
|
||||
const rawCommand = rest[0];
|
||||
const hasExplicitCommand = rawCommand && !rawCommand.startsWith("--");
|
||||
const command = hasExplicitCommand ? rawCommand : "run";
|
||||
const remaining = hasExplicitCommand ? rest.slice(1) : rest;
|
||||
|
||||
const options = {};
|
||||
const positional = [];
|
||||
for (const arg of remaining) {
|
||||
const m = /^--([^=]+)=(.*)$/.exec(arg);
|
||||
if (m) {
|
||||
options[m[1]] = m[2];
|
||||
} else {
|
||||
positional.push(arg);
|
||||
}
|
||||
}
|
||||
|
||||
return { command, options, positional };
|
||||
}
|
||||
|
||||
async function runBackup({ database } = {}) {
|
||||
const startedAt = Date.now();
|
||||
logger.info("===== バックアップ開始 =====");
|
||||
|
||||
let accessToken = null;
|
||||
let sharedriveId = null;
|
||||
let backupFolderId = null;
|
||||
|
||||
try {
|
||||
const pgDumpBin = process.env.PG_DUMP_BIN || "pg_dump";
|
||||
const host = process.env.PGHOST || "localhost";
|
||||
const port = process.env.PGPORT || "5432";
|
||||
const user = process.env.PGUSER || "postgres";
|
||||
const password = process.env.PGPASSWORD || "";
|
||||
const targetDatabase = database || process.env.PGDATABASE || "Implem.Pleasanter";
|
||||
const outputDir = process.env.BACKUP_LOCAL_DIR
|
||||
? path.resolve(process.env.BACKUP_LOCAL_DIR)
|
||||
: path.join(__dirname, "dump_tmp");
|
||||
const splitSizeMb = Number(process.env.PG_DUMP_SPLIT_SIZE_MB || 3072);
|
||||
const timestamp = formatTimestamp();
|
||||
|
||||
logger.info(
|
||||
`pg_dump開始: host=${host} port=${port} user=${user} db=${targetDatabase} 出力先=${outputDir}`
|
||||
);
|
||||
const dumpStartedAt = Date.now();
|
||||
const { files: dumpFiles, totalBytes } = await runPgDump({
|
||||
pgDumpBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database: targetDatabase,
|
||||
outputDir,
|
||||
splitSizeMb,
|
||||
timestamp,
|
||||
prefix: `[${os.hostname()}]postgres-`,
|
||||
});
|
||||
const dumpElapsedSec = ((Date.now() - dumpStartedAt) / 1000).toFixed(1);
|
||||
logger.info(
|
||||
`pg_dump終了: 合計サイズ=${totalBytes}bytes ファイル数=${dumpFiles.length} 所要時間=${dumpElapsedSec}秒`
|
||||
);
|
||||
|
||||
logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)");
|
||||
accessToken = await getUserAccessToken();
|
||||
logger.info("アクセストークン取得成功");
|
||||
|
||||
sharedriveId = await drive.resolveSharedDriveId(accessToken, logger);
|
||||
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(accessToken, sharedriveId, null, rootFolderName, logger);
|
||||
backupFolderId = backupFolder.fileId;
|
||||
|
||||
const dateFolderName = formatDateFolderName();
|
||||
const dateFolder = await drive.ensureFolder(
|
||||
accessToken,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
dateFolderName,
|
||||
logger
|
||||
);
|
||||
|
||||
for (const filePath of dumpFiles) {
|
||||
await drive.uploadFile(accessToken, sharedriveId, dateFolder.fileId, filePath, { overwrite: false }, logger);
|
||||
}
|
||||
|
||||
for (const filePath of dumpFiles) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
logger.info("ローカル一時ファイル削除完了");
|
||||
|
||||
// DB以外の環境設定ファイル(.envのCONFIG_BACKUP_PATHS)もあわせてバックアップする。
|
||||
// サーバー消失時の復元に必要なだけで、失敗してもDBバックアップ自体は成功扱いのまま続行する。
|
||||
try {
|
||||
const configPaths = parseConfigPaths();
|
||||
if (configPaths.length === 0) {
|
||||
logger.info("CONFIG_BACKUP_PATHS未設定のため、設定ファイルバックアップはスキップします");
|
||||
} else {
|
||||
logger.info(`設定ファイルバックアップ開始: 対象${configPaths.length}件`);
|
||||
const { archivePath, includedPaths } = await createConfigArchive({
|
||||
paths: configPaths,
|
||||
outputDir,
|
||||
timestamp,
|
||||
hostname: os.hostname(),
|
||||
logger,
|
||||
});
|
||||
logger.info(
|
||||
`設定アーカイブ作成完了: ${path.basename(archivePath)} ` +
|
||||
`(${includedPaths.length}件, ${fs.statSync(archivePath).size}bytes)`
|
||||
);
|
||||
await drive.uploadFile(accessToken, sharedriveId, dateFolder.fileId, archivePath, { overwrite: false }, logger);
|
||||
fs.unlinkSync(archivePath);
|
||||
logger.info("設定アーカイブのアップロード完了・ローカル一時ファイル削除完了");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`設定ファイルバックアップ失敗(DBバックアップ自体は継続): ${err.message}`);
|
||||
}
|
||||
|
||||
await rotateOldBackups(accessToken, sharedriveId, backupFolderId);
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`===== バックアップ終了(成功) ===== 所要時間=${elapsedSec}秒`);
|
||||
} catch (err) {
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.error(`===== バックアップ終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await uploadLogFile({ accessToken, sharedriveId, backupFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
async function rotateOldBackups(accessToken, sharedriveId, backupFolderId) {
|
||||
const retentionCount = Number(process.env.BACKUP_RETENTION_COUNT || 100);
|
||||
logger.info(`ローテーション開始 (保持件数=${retentionCount})`);
|
||||
|
||||
const children = await drive.listChildren(accessToken, sharedriveId, backupFolderId, logger);
|
||||
const dateFolders = children
|
||||
.filter((f) => f.fileType === "FOLDER" && /^\d{8}$/.test(f.fileName))
|
||||
.sort((a, b) => (a.fileName < b.fileName ? -1 : a.fileName > b.fileName ? 1 : 0));
|
||||
|
||||
const excess = dateFolders.length - retentionCount;
|
||||
if (excess <= 0) {
|
||||
logger.info(`ローテーション終了: 削除件数=0 (現在${dateFolders.length}件)`);
|
||||
return;
|
||||
}
|
||||
|
||||
const toDelete = dateFolders.slice(0, excess);
|
||||
for (const folder of toDelete) {
|
||||
await drive.deleteFile(accessToken, sharedriveId, folder.fileId, logger);
|
||||
logger.info(`削除: フォルダ名=${folder.fileName} fileId=${folder.fileId}`);
|
||||
}
|
||||
logger.info(`ローテーション終了: 削除件数=${toDelete.length}`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { command, options, positional } = parseArgs(process.argv);
|
||||
|
||||
if (command === "run") {
|
||||
await runBackup({ database: options.database });
|
||||
} else if (command === "create-test-db") {
|
||||
const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test";
|
||||
await createTestDb(dbName);
|
||||
} else if (command === "drop-test-db") {
|
||||
const dbName = positional[0] || process.env.TEST_DB_NAME || "pleasanter_backup_test";
|
||||
await dropTestDb(dbName);
|
||||
} else {
|
||||
console.log(
|
||||
"使い方: node backup.js [run [--database=<dbName>] | create-test-db [dbName] | drop-test-db [dbName]]"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
63
dbbackup/get-token.js
Normal file
63
dbbackup/get-token.js
Normal file
@ -0,0 +1,63 @@
|
||||
"use strict";
|
||||
/*
|
||||
* アクセストークンだけを標準出力するヘルパー
|
||||
* JWT組み立て・トークン取得の実体は lib/lineworksAuth.js (backup.js と共通)
|
||||
*
|
||||
* 使い方:
|
||||
* node get-token.js
|
||||
* → アクセストークン文字列だけが標準出力される(それ以外は何も出力しない)
|
||||
*
|
||||
* 必要な環境変数は .env に必ず設定すること(コード内にデフォルト値は持たない):
|
||||
* LW_CLIENT_ID / LW_CLIENT_SECRET / LW_SERVICE_ACCOUNT / LW_SCOPE
|
||||
* LW_PRIVATE_KEY または LW_PRIVATE_KEY_FILE
|
||||
* いずれかが未設定の場合は何も実行せずエラー終了する。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
require("./lib/env").loadEnv();
|
||||
const { getAccessToken, resolvePrivateKeyFile } = require("./lib/lineworksAuth");
|
||||
|
||||
const LW_CLIENT_ID = process.env.LW_CLIENT_ID;
|
||||
const LW_CLIENT_SECRET = process.env.LW_CLIENT_SECRET;
|
||||
const LW_SERVICE_ACCOUNT = process.env.LW_SERVICE_ACCOUNT;
|
||||
const LW_SCOPE = process.env.LW_SCOPE;
|
||||
const PRIVATE_KEY_FILE = resolvePrivateKeyFile();
|
||||
|
||||
function checkRequiredEnv() {
|
||||
const missing = [];
|
||||
if (!LW_CLIENT_ID) missing.push("LW_CLIENT_ID");
|
||||
if (!LW_CLIENT_SECRET) missing.push("LW_CLIENT_SECRET");
|
||||
if (!LW_SERVICE_ACCOUNT) missing.push("LW_SERVICE_ACCOUNT");
|
||||
if (!LW_SCOPE) missing.push("LW_SCOPE");
|
||||
if (!process.env.LW_PRIVATE_KEY && !fs.existsSync(PRIVATE_KEY_FILE)) {
|
||||
missing.push(`LW_PRIVATE_KEY_FILE (${PRIVATE_KEY_FILE} が存在しません)`);
|
||||
}
|
||||
return missing;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const missing = checkRequiredEnv();
|
||||
if (missing.length > 0) {
|
||||
process.stderr.write(`.env に必要な環境変数が設定されていません: ${missing.join(", ")}\n`);
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
// デバッグ用: 実際に使われている値をstderrに出力(トークン取得の可否には影響しない)
|
||||
process.stderr.write(`[DEBUG] LW_CLIENT_ID=${LW_CLIENT_ID}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_SERVICE_ACCOUNT=${LW_SERVICE_ACCOUNT}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_PRIVATE_KEY_FILE=${PRIVATE_KEY_FILE}\n`);
|
||||
process.stderr.write(`[DEBUG] 秘密鍵ファイル存在=${fs.existsSync(PRIVATE_KEY_FILE)}\n`);
|
||||
process.stderr.write(`[DEBUG] LW_SCOPE=${LW_SCOPE}\n`);
|
||||
|
||||
try {
|
||||
const token = await getAccessToken({ scope: LW_SCOPE });
|
||||
process.stdout.write(token);
|
||||
} catch (err) {
|
||||
process.stderr.write(`アクセストークン取得失敗: ${err.message}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
93
dbbackup/lib/backupLocator.js
Normal file
93
dbbackup/lib/backupLocator.js
Normal file
@ -0,0 +1,93 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* 共有ドライブ上の Pleasanter_DB_Backup 配下から、指定ホスト名の最新バックアップ
|
||||
* (同一タイムスタンプの分割ファイル一式)を特定する。restore.js が使用する。
|
||||
* 新しい日付フォルダから順に探索し、対象ホスト名のファイルが無ければ古いフォルダへ遡る
|
||||
* (多サーバー運用で、その日はまだ対象サーバーのバックアップが無い場合に対応するため)。
|
||||
*/
|
||||
|
||||
function parseBackupFileName(fileName, hostname, prefix) {
|
||||
const escapedHost = hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^\\[${escapedHost}\\]${prefix}-(\\d{8}-\\d{6})\\.dmp(\\d{3})$`);
|
||||
const m = re.exec(fileName);
|
||||
if (!m) return null;
|
||||
return { timestamp: m[1], partIndex: Number(m[2]) };
|
||||
}
|
||||
|
||||
async function findLatestBackupFileSet(
|
||||
token,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
hostname,
|
||||
drive,
|
||||
logger,
|
||||
prefix = "postgres"
|
||||
) {
|
||||
const dateFolders = (await drive.listChildren(token, sharedriveId, backupFolderId, logger))
|
||||
.filter((f) => f.fileType === "FOLDER" && /^\d{8}$/.test(f.fileName))
|
||||
.sort((a, b) => (a.fileName < b.fileName ? 1 : a.fileName > b.fileName ? -1 : 0));
|
||||
|
||||
for (const folder of dateFolders) {
|
||||
const children = await drive.listChildren(token, sharedriveId, folder.fileId, logger);
|
||||
|
||||
const matched = children
|
||||
.map((f) => {
|
||||
const parsed = parseBackupFileName(f.fileName, hostname, prefix);
|
||||
return parsed ? { file: f, ...parsed } : null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
|
||||
if (matched.length === 0) continue;
|
||||
|
||||
const latestTimestamp = matched.reduce(
|
||||
(max, m) => (m.timestamp > max ? m.timestamp : max),
|
||||
matched[0].timestamp
|
||||
);
|
||||
|
||||
const files = matched
|
||||
.filter((m) => m.timestamp === latestTimestamp)
|
||||
.sort((a, b) => a.partIndex - b.partIndex)
|
||||
.map((m) => m.file);
|
||||
|
||||
return { dateFolderName: folder.fileName, timestamp: latestTimestamp, files };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseConfigArchiveName(fileName, hostname) {
|
||||
const escapedHost = hostname.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const re = new RegExp(`^\\[${escapedHost}\\]config-(\\d{8}-\\d{6})\\.tar\\.gz$`);
|
||||
const m = re.exec(fileName);
|
||||
if (!m) return null;
|
||||
return { timestamp: m[1] };
|
||||
}
|
||||
|
||||
// 設定ファイルアーカイブ([hostname]config-<timestamp>.tar.gz)は分割されない単一ファイルのため、
|
||||
// findLatestBackupFileSetとは別に、日付フォルダを新しい順に探索して最初に見つかった1件を返す。
|
||||
async function findLatestConfigArchive(token, sharedriveId, backupFolderId, hostname, drive, logger) {
|
||||
const dateFolders = (await drive.listChildren(token, sharedriveId, backupFolderId, logger))
|
||||
.filter((f) => f.fileType === "FOLDER" && /^\d{8}$/.test(f.fileName))
|
||||
.sort((a, b) => (a.fileName < b.fileName ? 1 : a.fileName > b.fileName ? -1 : 0));
|
||||
|
||||
for (const folder of dateFolders) {
|
||||
const children = await drive.listChildren(token, sharedriveId, folder.fileId, logger);
|
||||
|
||||
const matched = children
|
||||
.map((f) => {
|
||||
const parsed = parseConfigArchiveName(f.fileName, hostname);
|
||||
return parsed ? { file: f, ...parsed } : null;
|
||||
})
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => (a.timestamp < b.timestamp ? 1 : a.timestamp > b.timestamp ? -1 : 0));
|
||||
|
||||
if (matched.length === 0) continue;
|
||||
|
||||
return { dateFolderName: folder.fileName, timestamp: matched[0].timestamp, file: matched[0].file };
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = { findLatestBackupFileSet, parseBackupFileName, findLatestConfigArchive, parseConfigArchiveName };
|
||||
61
dbbackup/lib/configBackup.js
Normal file
61
dbbackup/lib/configBackup.js
Normal file
@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* DB以外の環境設定ファイル一式(.envのCONFIG_BACKUP_PATHSで指定、カンマ区切り)を
|
||||
* tar.gzにまとめて共有ドライブへアップロードするための補助モジュール。
|
||||
* サーバーが完全に失われても同じ環境を再構築できるようにすることが目的。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function parseConfigPaths() {
|
||||
const raw = process.env.CONFIG_BACKUP_PATHS || "";
|
||||
return raw
|
||||
.split(",")
|
||||
.map((p) => p.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function runTar(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("tar", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
child.on("error", (err) => reject(new Error(`tar起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => resolve({ code, stderr: stderr.trim() }));
|
||||
});
|
||||
}
|
||||
|
||||
// paths のうち実在するものだけを対象にし、無いものは logger.warn するだけでスキップする
|
||||
// (サーバーによって存在しないパスがあっても全体を失敗させないため)。
|
||||
async function createConfigArchive({ paths, outputDir, timestamp, hostname, logger }) {
|
||||
const existingPaths = [];
|
||||
for (const p of paths) {
|
||||
if (fs.existsSync(p)) {
|
||||
existingPaths.push(p);
|
||||
} else {
|
||||
logger.warn(`設定バックアップ対象が見つかりません(スキップ): ${p}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (existingPaths.length === 0) {
|
||||
throw new Error("設定バックアップ対象のパスが1件も見つかりませんでした");
|
||||
}
|
||||
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
const archiveName = `[${hostname}]config-${timestamp}.tar.gz`;
|
||||
const archivePath = path.join(outputDir, archiveName);
|
||||
|
||||
// tarは絶対パスを渡すと「先頭の/を除去します」という警告(非ゼロ終了)を出すことがあるため、
|
||||
// 成否はcodeではなく実際にアーカイブが作られたかで判定する。
|
||||
const { stderr } = await runTar(["-czf", archivePath, "--", ...existingPaths]);
|
||||
if (!fs.existsSync(archivePath) || fs.statSync(archivePath).size === 0) {
|
||||
throw new Error(`設定アーカイブの作成に失敗しました: ${stderr}`);
|
||||
}
|
||||
|
||||
return { archivePath, includedPaths: existingPaths };
|
||||
}
|
||||
|
||||
module.exports = { parseConfigPaths, createConfigArchive };
|
||||
91
dbbackup/lib/configRestore.js
Normal file
91
dbbackup/lib/configRestore.js
Normal file
@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* 設定ファイルアーカイブ(tar.gz)を展開し、CONFIG_BACKUP_PATHS の各パスへ配置する。
|
||||
* 復元先に既存のファイル/ディレクトリがある場合は、必ず "<path>.pre-restore-<timestamp>" に
|
||||
* リネームして保持してから配置する(復元前の状態を失わないため)。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function runTar(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("tar", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
child.on("error", (err) => reject(new Error(`tar起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`tar展開失敗 (code=${code}): ${stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function extractArchive(archivePath, extractDir) {
|
||||
fs.mkdirSync(extractDir, { recursive: true });
|
||||
await runTar(["-xzf", archivePath, "-C", extractDir]);
|
||||
return extractDir;
|
||||
}
|
||||
|
||||
// tar展開時、絶対パスの先頭"/"は除去されて格納される(例: /etc/nginx → etc/nginx)。
|
||||
function extractedPathFor(extractDir, absolutePath) {
|
||||
return path.join(extractDir, absolutePath.replace(/^\/+/, ""));
|
||||
}
|
||||
|
||||
// シンボリックリンク(例: letsencryptの live/*.pem → ../../archive/*.pem という相対リンク)を
|
||||
// 配置する際、fs.cpSync({recursive:true}) は相対リンクを「コピー元ディレクトリを基準とした絶対パス」に
|
||||
// 書き換えてしまう(実機検証で確認済みの既知の挙動)。そのためNodeのfs APIではなく、
|
||||
// シンボリックリンクをそのまま複製する `cp -a` を使う。
|
||||
function copyPreservingSymlinks(source, dest) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("cp", ["-a", "--", source, dest], { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stderr = "";
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
child.on("error", (err) => reject(new Error(`cp起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`cp -a 失敗 (code=${code}): ${stderr.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// targetPaths(CONFIG_BACKUP_PATHSと同じ絶対パスの配列)それぞれについて、
|
||||
// 展開済みディレクトリに対応する中身があれば「既存を退避 → 配置」を行う。
|
||||
// 展開ファイル側に無いパスはスキップ(そのアーカイブに含まれていなかった=バックアップ時に存在しなかった扱い)。
|
||||
async function restoreConfigPaths({ extractDir, targetPaths, timestamp, logger }) {
|
||||
const results = [];
|
||||
|
||||
for (const targetPath of targetPaths) {
|
||||
const source = extractedPathFor(extractDir, targetPath);
|
||||
if (!fs.existsSync(source)) {
|
||||
logger.warn(`アーカイブ内に対象が見つからないためスキップ: ${targetPath}`);
|
||||
results.push({ targetPath, status: "skipped" });
|
||||
continue;
|
||||
}
|
||||
|
||||
let renamedTo = null;
|
||||
if (fs.existsSync(targetPath)) {
|
||||
renamedTo = `${targetPath}.pre-restore-${timestamp}`;
|
||||
fs.renameSync(targetPath, renamedTo);
|
||||
logger.info(`既存を退避: ${targetPath} → ${renamedTo}`);
|
||||
}
|
||||
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
await copyPreservingSymlinks(source, targetPath);
|
||||
logger.info(`配置完了: ${targetPath}`);
|
||||
|
||||
results.push({ targetPath, status: "restored", renamedTo });
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
module.exports = { extractArchive, restoreConfigPaths };
|
||||
61
dbbackup/lib/env.js
Normal file
61
dbbackup/lib/env.js
Normal file
@ -0,0 +1,61 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
const ENV_PATH = path.join(__dirname, "..", ".env");
|
||||
|
||||
function parseLine(line) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) return null;
|
||||
|
||||
const eq = trimmed.indexOf("=");
|
||||
if (eq === -1) return null;
|
||||
|
||||
const key = trimmed.slice(0, eq).trim();
|
||||
if (!key || !/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) return null;
|
||||
|
||||
let value = trimmed.slice(eq + 1).trim();
|
||||
if (
|
||||
(value.startsWith('"') && value.endsWith('"')) ||
|
||||
(value.startsWith("'") && value.endsWith("'"))
|
||||
) {
|
||||
value = value.slice(1, -1);
|
||||
}
|
||||
|
||||
return { key, value };
|
||||
}
|
||||
|
||||
// .env を読み込み process.env に反映する。.env ファイルの値を常に正として扱い、
|
||||
// 実行環境に同名の変数が既に存在していても .env の値で上書きする。
|
||||
function loadEnv(envPath = ENV_PATH) {
|
||||
if (!fs.existsSync(envPath)) return;
|
||||
|
||||
const lines = fs.readFileSync(envPath, "utf8").split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
const parsed = parseLine(line);
|
||||
if (!parsed) continue;
|
||||
process.env[parsed.key] = parsed.value;
|
||||
}
|
||||
}
|
||||
|
||||
// .env 内の KEY=... 行を書き換える(無ければ末尾に追加)。process.env も更新する。
|
||||
// LW_SHAREDRIVE_ID の永続化に使用する。
|
||||
function updateEnvValue(key, value, envPath = ENV_PATH) {
|
||||
const text = fs.existsSync(envPath) ? fs.readFileSync(envPath, "utf8") : "";
|
||||
const linePattern = new RegExp(`^${key}=.*$`, "m");
|
||||
const newLine = `${key}=${value}`;
|
||||
|
||||
let newText;
|
||||
if (linePattern.test(text)) {
|
||||
newText = text.replace(linePattern, newLine);
|
||||
} else {
|
||||
const separator = text.length === 0 || text.endsWith("\n") ? "" : "\n";
|
||||
newText = `${text}${separator}${newLine}\n`;
|
||||
}
|
||||
|
||||
fs.writeFileSync(envPath, newText, "utf8");
|
||||
process.env[key] = value;
|
||||
}
|
||||
|
||||
module.exports = { loadEnv, updateEnvValue, ENV_PATH };
|
||||
115
dbbackup/lib/lineworksAuth.js
Normal file
115
dbbackup/lib/lineworksAuth.js
Normal file
@ -0,0 +1,115 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS Service AccountのJWT自己署名 → OAuth2アクセストークン取得。
|
||||
* get-token.js (CLI、掲示板等のService Account対応APIの動作確認用) が使用する。
|
||||
* Drive/共有ドライブAPIはService Account認証に非対応のため、backup.jsは
|
||||
* 代わりに lib/lineworksUserAuth.js (User Account認証) を使用する。
|
||||
* ロジックは元々 get-token.js / lineworks-anythingllm.js にあったものを移設。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const crypto = require("crypto");
|
||||
|
||||
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
|
||||
|
||||
function resolvePrivateKeyFile() {
|
||||
return process.env.LW_PRIVATE_KEY_FILE
|
||||
? path.resolve(process.env.LW_PRIVATE_KEY_FILE)
|
||||
: path.join(__dirname, "..", "private_20260307184804.key");
|
||||
}
|
||||
|
||||
function resolvePrivateKey() {
|
||||
if (process.env.LW_PRIVATE_KEY && process.env.LW_PRIVATE_KEY.trim()) {
|
||||
return process.env.LW_PRIVATE_KEY;
|
||||
}
|
||||
const file = resolvePrivateKeyFile();
|
||||
return fs.existsSync(file) ? fs.readFileSync(file, "utf8") : "";
|
||||
}
|
||||
|
||||
function base64UrlEncode(value) {
|
||||
return Buffer.from(value)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
}
|
||||
|
||||
function createJwtAssertion({ clientId, serviceAccount, privateKey }) {
|
||||
const now = Math.floor(Date.now() / 1000);
|
||||
const header = { alg: "RS256", typ: "JWT" };
|
||||
const payload = {
|
||||
iss: clientId,
|
||||
sub: serviceAccount,
|
||||
aud: LW_TOKEN_URL,
|
||||
iat: now,
|
||||
exp: now + 300,
|
||||
};
|
||||
|
||||
const encodedHeader = base64UrlEncode(JSON.stringify(header));
|
||||
const encodedPayload = base64UrlEncode(JSON.stringify(payload));
|
||||
const signingInput = `${encodedHeader}.${encodedPayload}`;
|
||||
|
||||
const signer = crypto.createSign("RSA-SHA256");
|
||||
signer.update(signingInput);
|
||||
signer.end();
|
||||
|
||||
const signature = signer
|
||||
.sign(privateKey)
|
||||
.toString("base64")
|
||||
.replace(/=/g, "")
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_");
|
||||
|
||||
return `${signingInput}.${signature}`;
|
||||
}
|
||||
|
||||
// clientId/clientSecret/serviceAccount にコード側のデフォルト値は持たない。
|
||||
// 必ず .env(または呼び出し元が明示的に渡すoptions)から取得し、無ければ即座にエラーとする。
|
||||
// scope は呼び出し元(get-token.js / backup.js)ごとに用途が異なるため必須の引数として扱う。
|
||||
async function getAccessToken(options = {}) {
|
||||
const clientId = options.clientId || process.env.LW_CLIENT_ID;
|
||||
const clientSecret = options.clientSecret || process.env.LW_CLIENT_SECRET;
|
||||
const serviceAccount = options.serviceAccount || process.env.LW_SERVICE_ACCOUNT;
|
||||
const scope = options.scope !== undefined ? options.scope : process.env.LW_SCOPE;
|
||||
const privateKey = options.privateKey || resolvePrivateKey();
|
||||
|
||||
const missing = [];
|
||||
if (!clientId) missing.push("LW_CLIENT_ID");
|
||||
if (!clientSecret) missing.push("LW_CLIENT_SECRET");
|
||||
if (!serviceAccount) missing.push("LW_SERVICE_ACCOUNT");
|
||||
if (!privateKey) missing.push(`LW_PRIVATE_KEY または LW_PRIVATE_KEY_FILE (${resolvePrivateKeyFile()} が存在しません)`);
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`環境変数が不足しています: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
const assertion = createJwtAssertion({ clientId, serviceAccount, privateKey });
|
||||
const form = new URLSearchParams({
|
||||
grant_type: "urn:ietf:params:oauth:grant-type:jwt-bearer",
|
||||
assertion,
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
scope: scope || "",
|
||||
});
|
||||
|
||||
const response = await fetch(LW_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const detail = await response.text();
|
||||
throw new Error(`アクセストークン取得失敗: ${response.status} ${detail}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.access_token) {
|
||||
throw new Error("アクセストークン取得失敗: access_token が返却されませんでした");
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
module.exports = { getAccessToken, resolvePrivateKeyFile, resolvePrivateKey };
|
||||
317
dbbackup/lib/lineworksDrive.js
Normal file
317
dbbackup/lib/lineworksDrive.js
Normal file
@ -0,0 +1,317 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS 共有ドライブAPIのラッパー。
|
||||
* 仕様書:
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-list
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-file-folder-create
|
||||
* https://developers.worksmobile.com/jp/docs/sharedrive-file-create
|
||||
* https://developers.worksmobile.com/en/docs/file-upload (アップロード段階2の詳細)
|
||||
*
|
||||
* 必要スコープ: file (読み書き全般)
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { pipeline } = require("stream/promises");
|
||||
const { Readable } = require("stream");
|
||||
|
||||
const { updateEnvValue } = require("./env");
|
||||
const defaultLogger = require("./logger");
|
||||
|
||||
const LW_API_BASE_URL = "https://www.worksapis.com/v1.0";
|
||||
|
||||
// 各API呼び出しの間隔(ミリ秒)とレート制限時の最大リトライ回数。
|
||||
// lineworks-anythingllm.js の lwFetch と同じスロットリング方式。
|
||||
const LW_REQUEST_DELAY_MS = Number(process.env.LW_REQUEST_DELAY_MS || 250);
|
||||
const LW_MAX_RETRIES = Number(process.env.LW_MAX_RETRIES || 5);
|
||||
|
||||
function sleep(ms) {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
async function lwFetch(url, options = {}, logger = defaultLogger) {
|
||||
for (let attempt = 0; attempt <= LW_MAX_RETRIES; attempt++) {
|
||||
await sleep(LW_REQUEST_DELAY_MS);
|
||||
|
||||
const response = await fetch(url, options);
|
||||
|
||||
if (response.status !== 429) {
|
||||
return response;
|
||||
}
|
||||
if (attempt === LW_MAX_RETRIES) {
|
||||
return response;
|
||||
}
|
||||
|
||||
const retryAfterHeader = response.headers.get("retry-after");
|
||||
const retryAfterMs = retryAfterHeader
|
||||
? Number(retryAfterHeader) * 1000
|
||||
: LW_REQUEST_DELAY_MS * Math.pow(2, attempt + 1);
|
||||
|
||||
logger.warn(
|
||||
`[Rate Limit] 429を検知。${Math.round(retryAfterMs / 1000)}秒待機してリトライします... ` +
|
||||
`(${attempt + 1}/${LW_MAX_RETRIES}) url=${url}`
|
||||
);
|
||||
await sleep(retryAfterMs);
|
||||
}
|
||||
}
|
||||
|
||||
async function parseJsonResponse(response, errorLabel) {
|
||||
const raw = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch (_err) {
|
||||
throw new Error(`${errorLabel}のレスポンスがJSONではありません: ${raw}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`${errorLabel}失敗: ${response.status} ${JSON.stringify(data)}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 共有ドライブID解決
|
||||
// ====================================================================
|
||||
|
||||
// GET /sharedrives にはページング用パラメータ(count/cursor)が存在しない
|
||||
// (/files 系のようなresponseMetaData.nextCursorは返らない)ため、1回のみ呼び出す。
|
||||
async function listSharedDrives(token, logger = defaultLogger) {
|
||||
const res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
logger
|
||||
);
|
||||
const data = await parseJsonResponse(res, "共有ドライブ一覧取得");
|
||||
|
||||
return Array.isArray(data.sharedrives) ? data.sharedrives : [];
|
||||
}
|
||||
|
||||
// .env の LW_SHAREDRIVE_ID があればそれを使用(APIを呼ばない)。無ければ
|
||||
// LW_SHAREDRIVE_NAME と一致する共有ドライブを検索し、.env に書き戻して次回以降キャッシュする。
|
||||
async function resolveSharedDriveId(token, logger = defaultLogger) {
|
||||
const cached = process.env.LW_SHAREDRIVE_ID;
|
||||
if (cached) {
|
||||
logger.info(`共有ドライブID: キャッシュ値を使用 (${cached})`);
|
||||
return cached;
|
||||
}
|
||||
|
||||
const targetName = process.env.LW_SHAREDRIVE_NAME || "システムバックアップ";
|
||||
logger.info(`共有ドライブID未キャッシュ。名前で検索します: ${targetName}`);
|
||||
|
||||
const drives = await listSharedDrives(token, logger);
|
||||
const found = drives.find((d) => d.name === targetName);
|
||||
if (!found) {
|
||||
throw new Error(`共有ドライブが見つかりません: ${targetName}`);
|
||||
}
|
||||
|
||||
updateEnvValue("LW_SHAREDRIVE_ID", found.sharedriveId);
|
||||
logger.info(`共有ドライブID解決: name=${targetName} id=${found.sharedriveId} (.envへ保存)`);
|
||||
|
||||
return found.sharedriveId;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// フォルダ一覧・作成
|
||||
// ====================================================================
|
||||
|
||||
// parentFileId が falsy の場合はルート直下を対象にする。
|
||||
async function listChildren(token, sharedriveId, parentFileId, logger = defaultLogger) {
|
||||
const files = [];
|
||||
let cursor = "";
|
||||
const basePath = parentFileId
|
||||
? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/children`
|
||||
: `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files`;
|
||||
|
||||
while (true) {
|
||||
const params = new URLSearchParams();
|
||||
params.set("count", "200");
|
||||
if (cursor) params.set("cursor", cursor);
|
||||
|
||||
const res = await lwFetch(
|
||||
`${basePath}?${params.toString()}`,
|
||||
{ headers: { Authorization: `Bearer ${token}` } },
|
||||
logger
|
||||
);
|
||||
const data = await parseJsonResponse(res, "ファイル一覧取得");
|
||||
|
||||
const page = Array.isArray(data.files) ? data.files : [];
|
||||
files.push(...page);
|
||||
|
||||
const nextCursor = data?.responseMetaData?.nextCursor || "";
|
||||
if (!nextCursor || nextCursor === cursor) break;
|
||||
cursor = nextCursor;
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
async function createFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) {
|
||||
const url = parentFileId
|
||||
? `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}/createfolder`
|
||||
: `${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/createfolder`;
|
||||
|
||||
const res = await lwFetch(
|
||||
url,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ fileName: folderName }),
|
||||
},
|
||||
logger
|
||||
);
|
||||
|
||||
return parseJsonResponse(res, `フォルダ作成 (${folderName})`);
|
||||
}
|
||||
|
||||
// 同名フォルダが既にあれば再利用し、無ければ作成する。
|
||||
async function ensureFolder(token, sharedriveId, parentFileId, folderName, logger = defaultLogger) {
|
||||
const children = await listChildren(token, sharedriveId, parentFileId, logger);
|
||||
const existing = children.find((f) => f.fileType === "FOLDER" && f.fileName === folderName);
|
||||
if (existing) {
|
||||
logger.info(`フォルダ確認: 既存を使用 name=${folderName} id=${existing.fileId}`);
|
||||
return existing;
|
||||
}
|
||||
|
||||
const created = await createFolder(token, sharedriveId, parentFileId, folderName, logger);
|
||||
logger.info(`フォルダ確認: 新規作成 name=${folderName} id=${created.fileId}`);
|
||||
return created;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// アップロード (段階1: uploadUrl発行 → 段階2: multipart/form-dataで実バイト送信)
|
||||
// ====================================================================
|
||||
|
||||
async function fileToBlob(filePath, mimeType) {
|
||||
if (typeof fs.openAsBlob === "function") {
|
||||
// Node 18.13+: ファイル全体をメモリに載せずBlobとして扱える
|
||||
return fs.openAsBlob(filePath, { type: mimeType });
|
||||
}
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
return new Blob([buffer], { type: mimeType });
|
||||
}
|
||||
|
||||
async function uploadFile(token, sharedriveId, parentFileId, filePath, options = {}, logger = defaultLogger) {
|
||||
const overwrite = options.overwrite === true;
|
||||
const stat = fs.statSync(filePath);
|
||||
const fileName = path.basename(filePath);
|
||||
|
||||
logger.info(`アップロード開始: ファイル名=${fileName} サイズ=${stat.size}bytes`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
const stage1Res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(parentFileId)}`,
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
fileName,
|
||||
modifiedTime: new Date(stat.mtimeMs).toISOString(),
|
||||
fileSize: stat.size,
|
||||
overwrite,
|
||||
resume: false,
|
||||
suffixOnDuplicate: false,
|
||||
}),
|
||||
},
|
||||
logger
|
||||
);
|
||||
const stage1Data = await parseJsonResponse(stage1Res, `アップロードURL発行 (${fileName})`);
|
||||
if (!stage1Data.uploadUrl) {
|
||||
throw new Error(`アップロードURL発行失敗 (${fileName}): uploadUrlが返却されませんでした`);
|
||||
}
|
||||
|
||||
const blob = await fileToBlob(filePath, "application/octet-stream");
|
||||
const form = new FormData();
|
||||
form.append("Filedata", blob, fileName);
|
||||
|
||||
const stage2Res = await lwFetch(
|
||||
stage1Data.uploadUrl,
|
||||
{
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
body: form,
|
||||
},
|
||||
logger
|
||||
);
|
||||
const stage2Data = await parseJsonResponse(stage2Res, `アップロード (${fileName})`);
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`アップロード成功: ファイル名=${fileName} fileId=${stage2Data.fileId} 所要時間=${elapsedSec}秒`);
|
||||
|
||||
return stage2Data;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// ダウンロード (段階1: /download で302リダイレクト先URLを取得 → 段階2: そのURLから実バイト取得)
|
||||
// 仕様書: https://developers.worksmobile.com/jp/docs/sharedrive-file-download
|
||||
// ====================================================================
|
||||
|
||||
async function downloadFile(token, sharedriveId, fileId, destPath, logger = defaultLogger) {
|
||||
const fileName = path.basename(destPath);
|
||||
logger.info(`ダウンロード開始: ファイル名=${fileName} fileId=${fileId}`);
|
||||
const startedAt = Date.now();
|
||||
|
||||
const stage1Res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(fileId)}/download`,
|
||||
{ headers: { Authorization: `Bearer ${token}` }, redirect: "manual" },
|
||||
logger
|
||||
);
|
||||
if (stage1Res.status !== 302 && stage1Res.status !== 303) {
|
||||
const raw = await stage1Res.text().catch(() => "");
|
||||
throw new Error(`ダウンロードURL取得失敗 (${fileName}): ${stage1Res.status} ${raw}`);
|
||||
}
|
||||
const downloadUrl = stage1Res.headers.get("location");
|
||||
if (!downloadUrl) {
|
||||
throw new Error(`ダウンロードURL取得失敗 (${fileName}): Locationヘッダーがありません`);
|
||||
}
|
||||
|
||||
const stage2Res = await lwFetch(downloadUrl, { headers: { Authorization: `Bearer ${token}` } }, logger);
|
||||
if (!stage2Res.ok || !stage2Res.body) {
|
||||
throw new Error(`ファイルダウンロード失敗 (${fileName}): ${stage2Res.status}`);
|
||||
}
|
||||
|
||||
await pipeline(Readable.fromWeb(stage2Res.body), fs.createWriteStream(destPath));
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
const size = fs.statSync(destPath).size;
|
||||
logger.info(`ダウンロード成功: ファイル名=${fileName} サイズ=${size}bytes 所要時間=${elapsedSec}秒`);
|
||||
|
||||
return destPath;
|
||||
}
|
||||
|
||||
// ====================================================================
|
||||
// 削除
|
||||
// ====================================================================
|
||||
|
||||
async function deleteFile(token, sharedriveId, fileId, logger = defaultLogger) {
|
||||
const res = await lwFetch(
|
||||
`${LW_API_BASE_URL}/sharedrives/${encodeURIComponent(sharedriveId)}/files/${encodeURIComponent(fileId)}`,
|
||||
{
|
||||
method: "DELETE",
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
},
|
||||
logger
|
||||
);
|
||||
|
||||
if (res.status !== 204 && !res.ok) {
|
||||
const raw = await res.text();
|
||||
throw new Error(`削除失敗 (${fileId}): ${res.status} ${raw}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
LW_API_BASE_URL,
|
||||
listSharedDrives,
|
||||
resolveSharedDriveId,
|
||||
listChildren,
|
||||
ensureFolder,
|
||||
uploadFile,
|
||||
downloadFile,
|
||||
deleteFile,
|
||||
};
|
||||
120
dbbackup/lib/lineworksUserAuth.js
Normal file
120
dbbackup/lib/lineworksUserAuth.js
Normal file
@ -0,0 +1,120 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* LINE WORKS User Account認証 (OAuth 2.0 認可コードフロー)。
|
||||
* Drive/共有ドライブAPIはService Account認証(JWT)では利用できないため、
|
||||
* backup.js のドライブ操作はこちらの実ユーザー認証を使う。
|
||||
*
|
||||
* 初回セットアップ: `node lineworks-authorize.js` を一度だけ実行し、
|
||||
* ブラウザでの認可 → refresh_token取得 → .envへの保存 を行う。
|
||||
* 以降 backup.js は getUserAccessToken() で refresh_token から
|
||||
* access_token を都度再発行する(ユーザー操作不要)。
|
||||
*
|
||||
* 仕様書: https://developers.worksmobile.com/jp/docs/auth-oauth
|
||||
*/
|
||||
|
||||
const { updateEnvValue } = require("./env");
|
||||
|
||||
const LW_AUTHORIZE_URL = "https://auth.worksmobile.com/oauth2/v2.0/authorize";
|
||||
const LW_TOKEN_URL = "https://auth.worksmobile.com/oauth2/v2.0/token";
|
||||
|
||||
function requiredEnv(key) {
|
||||
const value = process.env[key];
|
||||
if (!value) {
|
||||
throw new Error(`環境変数が不足しています: ${key}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function buildAuthorizeUrl({ state }) {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const redirectUri = requiredEnv("LW_REDIRECT_URI");
|
||||
const scope = requiredEnv("LW_SCOPE_DRIVE");
|
||||
|
||||
const params = new URLSearchParams({
|
||||
client_id: clientId,
|
||||
redirect_uri: redirectUri,
|
||||
response_type: "code",
|
||||
scope,
|
||||
state,
|
||||
});
|
||||
|
||||
return `${LW_AUTHORIZE_URL}?${params.toString()}`;
|
||||
}
|
||||
|
||||
async function requestToken(form) {
|
||||
const response = await fetch(LW_TOKEN_URL, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body: form,
|
||||
});
|
||||
|
||||
const raw = await response.text();
|
||||
let data;
|
||||
try {
|
||||
data = raw ? JSON.parse(raw) : {};
|
||||
} catch (_err) {
|
||||
throw new Error(`トークンエンドポイントのレスポンスがJSONではありません: ${raw}`);
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`トークン取得失敗: ${response.status} ${raw}`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
async function exchangeCodeForToken(code) {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const clientSecret = requiredEnv("LW_CLIENT_SECRET");
|
||||
const redirectUri = requiredEnv("LW_REDIRECT_URI");
|
||||
|
||||
return requestToken(
|
||||
new URLSearchParams({
|
||||
code,
|
||||
grant_type: "authorization_code",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
redirect_uri: redirectUri,
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
// refresh_token から access_token を再発行する。Refresh Token Rotation が ON のため、
|
||||
// レスポンスに含まれる新しい refresh_token を必ず .env へ書き戻してから access_token を返す。
|
||||
async function getUserAccessToken() {
|
||||
const clientId = requiredEnv("LW_CLIENT_ID");
|
||||
const clientSecret = requiredEnv("LW_CLIENT_SECRET");
|
||||
const refreshToken = process.env.LW_USER_REFRESH_TOKEN;
|
||||
|
||||
if (!refreshToken) {
|
||||
throw new Error(
|
||||
"LW_USER_REFRESH_TOKEN が未設定です。先に 'node lineworks-authorize.js' を実行してください。"
|
||||
);
|
||||
}
|
||||
|
||||
const data = await requestToken(
|
||||
new URLSearchParams({
|
||||
refresh_token: refreshToken,
|
||||
grant_type: "refresh_token",
|
||||
client_id: clientId,
|
||||
client_secret: clientSecret,
|
||||
})
|
||||
);
|
||||
|
||||
if (!data.access_token) {
|
||||
throw new Error("アクセストークン再発行失敗: access_token が返却されませんでした");
|
||||
}
|
||||
|
||||
if (data.refresh_token) {
|
||||
updateEnvValue("LW_USER_REFRESH_TOKEN", data.refresh_token);
|
||||
}
|
||||
|
||||
return data.access_token;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAuthorizeUrl,
|
||||
exchangeCodeForToken,
|
||||
getUserAccessToken,
|
||||
LW_AUTHORIZE_URL,
|
||||
LW_TOKEN_URL,
|
||||
};
|
||||
46
dbbackup/lib/logUpload.js
Normal file
46
dbbackup/lib/logUpload.js
Normal file
@ -0,0 +1,46 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* バックアップ/リストア処理の成否に関わらず、当月ログファイルを
|
||||
* Pleasanter_DB_Backup/logs 配下へ上書きアップロードする。backup.js / restore.js 共用。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
|
||||
const logger = require("./logger");
|
||||
const { getUserAccessToken } = require("./lineworksUserAuth");
|
||||
const drive = require("./lineworksDrive");
|
||||
|
||||
async function uploadLogFile({ accessToken, sharedriveId, backupFolderId } = {}) {
|
||||
logger.info("ログファイルアップロード開始");
|
||||
try {
|
||||
let token = accessToken;
|
||||
let driveId = sharedriveId;
|
||||
let folderId = backupFolderId;
|
||||
|
||||
if (!token) {
|
||||
token = await getUserAccessToken();
|
||||
}
|
||||
if (!driveId) {
|
||||
driveId = await drive.resolveSharedDriveId(token, logger);
|
||||
}
|
||||
if (!folderId) {
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(token, driveId, null, rootFolderName, logger);
|
||||
folderId = backupFolder.fileId;
|
||||
}
|
||||
|
||||
const logFolderName = process.env.LW_LOG_FOLDER_NAME || "logs";
|
||||
const logsFolder = await drive.ensureFolder(token, driveId, folderId, logFolderName, logger);
|
||||
|
||||
const logFilePath = logger.currentLogFilePath();
|
||||
if (fs.existsSync(logFilePath)) {
|
||||
await drive.uploadFile(token, driveId, logsFolder.fileId, logFilePath, { overwrite: true }, logger);
|
||||
logger.info("ログファイルアップロード成功");
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error(`ログファイルアップロード失敗: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { uploadLogFile };
|
||||
62
dbbackup/lib/logger.js
Normal file
62
dbbackup/lib/logger.js
Normal file
@ -0,0 +1,62 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* 月次ローテーションするローカルログファイルへの書き込み。
|
||||
* 出力先: LOG_LOCAL_DIR/backup-YYYYMM.log (月が変わると自動的に別ファイルへ切り替わる)
|
||||
* 各行: YYYY-MM-DD HH:mm:ss [LEVEL] message
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
|
||||
function pad(n, width = 2) {
|
||||
return String(n).padStart(width, "0");
|
||||
}
|
||||
|
||||
function timestamp(date = new Date()) {
|
||||
return (
|
||||
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ` +
|
||||
`${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
// LOG_LOCAL_DIR は実行時に .env 読み込み後の process.env を見たいため、
|
||||
// モジュール読み込み時点では確定させず呼び出しの都度評価する。
|
||||
function getLogDir() {
|
||||
return process.env.LOG_LOCAL_DIR
|
||||
? path.resolve(process.env.LOG_LOCAL_DIR)
|
||||
: path.join(__dirname, "..", "logs");
|
||||
}
|
||||
|
||||
function currentLogFilePath(date = new Date()) {
|
||||
const yyyymm = `${date.getFullYear()}${pad(date.getMonth() + 1)}`;
|
||||
return path.join(getLogDir(), `backup-${yyyymm}.log`);
|
||||
}
|
||||
|
||||
function writeLine(level, message) {
|
||||
const line = `${timestamp()} [${level}] ${message}`;
|
||||
|
||||
if (level === "WARN") {
|
||||
console.warn(line);
|
||||
} else if (level === "ERROR") {
|
||||
console.error(line);
|
||||
} else {
|
||||
console.log(line);
|
||||
}
|
||||
|
||||
try {
|
||||
const logDir = getLogDir();
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
fs.appendFileSync(currentLogFilePath(), line + "\n", "utf8");
|
||||
} catch (err) {
|
||||
console.error(`[logger] ログファイルへの書き込みに失敗しました: ${err.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
info: (message) => writeLine("INFO", message),
|
||||
warn: (message) => writeLine("WARN", message),
|
||||
error: (message) => writeLine("ERROR", message),
|
||||
currentLogFilePath,
|
||||
getLogDir,
|
||||
};
|
||||
121
dbbackup/lib/pgDump.js
Normal file
121
dbbackup/lib/pgDump.js
Normal file
@ -0,0 +1,121 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* pg_dump をspawnし、標準出力を splitSizeMb ごとにローカルファイルへ分割書き出しする。
|
||||
* `split -d -a 3` 相当の3桁ゼロ埋め連番(000, 001, ...)をファイル名末尾に付与する。
|
||||
*/
|
||||
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function pad2(n) {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function formatTimestamp(date = new Date()) {
|
||||
return (
|
||||
`${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}-` +
|
||||
`${pad2(date.getHours())}${pad2(date.getMinutes())}${pad2(date.getSeconds())}`
|
||||
);
|
||||
}
|
||||
|
||||
function runPgDump({
|
||||
pgDumpBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database,
|
||||
outputDir,
|
||||
splitSizeMb,
|
||||
prefix = "postgres-",
|
||||
ext = ".dmp",
|
||||
timestamp = formatTimestamp(),
|
||||
}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs.mkdirSync(outputDir, { recursive: true });
|
||||
|
||||
const splitSizeBytes = Number(splitSizeMb) * 1024 * 1024;
|
||||
const baseName = `${prefix}${timestamp}${ext}`;
|
||||
|
||||
const args = ["-h", host, "-p", String(port), "-U", user, "-Fc", database];
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
env.PGPASSWORD = password;
|
||||
}
|
||||
|
||||
const child = spawn(pgDumpBin, args, { env });
|
||||
|
||||
const files = [];
|
||||
let partIndex = 0;
|
||||
let currentStream = null;
|
||||
let currentBytes = 0;
|
||||
let totalBytes = 0;
|
||||
let stderrOutput = "";
|
||||
let settled = false;
|
||||
|
||||
function openNextPart() {
|
||||
const fileName = `${baseName}${String(partIndex).padStart(3, "0")}`;
|
||||
const filePath = path.join(outputDir, fileName);
|
||||
files.push(filePath);
|
||||
currentStream = fs.createWriteStream(filePath);
|
||||
currentBytes = 0;
|
||||
partIndex += 1;
|
||||
}
|
||||
|
||||
openNextPart();
|
||||
|
||||
child.stdout.on("data", (chunk) => {
|
||||
let offset = 0;
|
||||
while (offset < chunk.length) {
|
||||
if (currentBytes >= splitSizeBytes) {
|
||||
currentStream.end();
|
||||
openNextPart();
|
||||
}
|
||||
const remaining = splitSizeBytes - currentBytes;
|
||||
const writeLen = Math.min(remaining, chunk.length - offset);
|
||||
currentStream.write(chunk.subarray(offset, offset + writeLen));
|
||||
currentBytes += writeLen;
|
||||
totalBytes += writeLen;
|
||||
offset += writeLen;
|
||||
}
|
||||
});
|
||||
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrOutput += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (currentStream) currentStream.end();
|
||||
reject(new Error(`pg_dump起動失敗: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (settled) return;
|
||||
|
||||
const finalize = () => {
|
||||
settled = true;
|
||||
if (code !== 0) {
|
||||
reject(new Error(`pg_dump異常終了 (code=${code}): ${stderrOutput.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ files, totalBytes, stderrOutput: stderrOutput.trim() });
|
||||
};
|
||||
|
||||
if (currentStream && !currentStream.destroyed) {
|
||||
currentStream.end(finalize);
|
||||
} else {
|
||||
finalize();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateFolderName(date = new Date()) {
|
||||
return `${date.getFullYear()}${pad2(date.getMonth() + 1)}${pad2(date.getDate())}`;
|
||||
}
|
||||
|
||||
module.exports = { runPgDump, formatTimestamp, formatDateFolderName };
|
||||
39
dbbackup/lib/pgRestore.js
Normal file
39
dbbackup/lib/pgRestore.js
Normal file
@ -0,0 +1,39 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* pg_restore をspawnし、カスタム形式(-Fc)のダンプファイルからDBへ復元する。
|
||||
* ダンプファイルは分割済みパートを事前に1ファイルへ再結合したもの(restore.js側で実施)を渡す想定。
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function runPgRestore({ pgRestoreBin, host, port, user, password, database, dumpFilePath }) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const args = ["-h", host, "-p", String(port), "-U", user, "-d", database, "--no-owner", dumpFilePath];
|
||||
const env = { ...process.env };
|
||||
if (password) {
|
||||
env.PGPASSWORD = password;
|
||||
}
|
||||
|
||||
const child = spawn(pgRestoreBin, args, { env });
|
||||
|
||||
let stderrOutput = "";
|
||||
child.stderr.on("data", (chunk) => {
|
||||
stderrOutput += chunk.toString("utf8");
|
||||
});
|
||||
|
||||
child.on("error", (err) => {
|
||||
reject(new Error(`pg_restore起動失敗: ${err.message}`));
|
||||
});
|
||||
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`pg_restore異常終了 (code=${code}): ${stderrOutput.trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stderrOutput: stderrOutput.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { runPgRestore };
|
||||
47
dbbackup/lib/serviceControl.js
Normal file
47
dbbackup/lib/serviceControl.js
Normal file
@ -0,0 +1,47 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* systemdサービスの停止・起動・状態確認。restore.js がリストア前後に
|
||||
* Webサービス(既定 pleasanter)を安全に止める/戻すために使用する。
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function runSystemctl(args) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("systemctl", args, { stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
|
||||
child.on("error", (err) => reject(new Error(`systemctl起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`systemctl ${args.join(" ")} 異常終了 (code=${code}): ${(stderr || stdout).trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function isServiceActive(serviceName) {
|
||||
try {
|
||||
const { stdout } = await runSystemctl(["is-active", serviceName]);
|
||||
return stdout === "active";
|
||||
} catch (_err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopService(serviceName) {
|
||||
await runSystemctl(["stop", serviceName]);
|
||||
}
|
||||
|
||||
async function startService(serviceName) {
|
||||
await runSystemctl(["start", serviceName]);
|
||||
}
|
||||
|
||||
module.exports = { isServiceActive, stopService, startService };
|
||||
91
dbbackup/lib/testDb.js
Normal file
91
dbbackup/lib/testDb.js
Normal file
@ -0,0 +1,91 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* バックアップパイプライン全体(pg_dump→分割→アップロード→ローテーション→ログ)を
|
||||
* 本番DBに触れずに試せるよう、テスト用DBを作成・削除するヘルパー。
|
||||
* PGHOST/PGPORT/PGUSER/PGPASSWORD は通常バックアップと同じ接続情報を再利用する。
|
||||
*/
|
||||
|
||||
const { spawn } = require("child_process");
|
||||
|
||||
function runCommand(bin, args, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(bin, args, { env, stdio: ["ignore", "pipe", "pipe"] });
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
|
||||
child.stdout.on("data", (d) => (stdout += d.toString("utf8")));
|
||||
child.stderr.on("data", (d) => (stderr += d.toString("utf8")));
|
||||
|
||||
child.on("error", (err) => reject(new Error(`${bin} 起動失敗: ${err.message}`)));
|
||||
child.on("close", (code) => {
|
||||
if (code !== 0) {
|
||||
reject(new Error(`${bin} 異常終了 (code=${code}): ${(stderr || stdout).trim()}`));
|
||||
return;
|
||||
}
|
||||
resolve({ stdout: stdout.trim(), stderr: stderr.trim() });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildEnv() {
|
||||
const env = { ...process.env };
|
||||
if (process.env.PGPASSWORD) {
|
||||
env.PGPASSWORD = process.env.PGPASSWORD;
|
||||
}
|
||||
return env;
|
||||
}
|
||||
|
||||
function connectionInfo() {
|
||||
return {
|
||||
host: process.env.PGHOST || "localhost",
|
||||
port: process.env.PGPORT || "5432",
|
||||
user: process.env.PGUSER || "postgres",
|
||||
};
|
||||
}
|
||||
|
||||
async function createTestDb(dbName) {
|
||||
const bin = process.env.PG_CREATEDB_BIN || "createdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
console.log(`テスト用DB作成: ${dbName} (host=${host} port=${port} user=${user})`);
|
||||
try {
|
||||
await runCommand(bin, ["-h", host, "-p", String(port), "-U", user, dbName], buildEnv());
|
||||
console.log(`テスト用DB作成完了: ${dbName}`);
|
||||
} catch (err) {
|
||||
console.error(`テスト用DB作成失敗: ${err.message}`);
|
||||
console.error(`既に存在する場合は先に 'node backup.js drop-test-db ${dbName}' を実行してください。`);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async function dropTestDb(dbName) {
|
||||
const bin = process.env.PG_DROPDB_BIN || "dropdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
console.log(`テスト用DB削除: ${dbName} (host=${host} port=${port} user=${user})`);
|
||||
await runCommand(bin, ["-h", host, "-p", String(port), "-U", user, "--if-exists", dbName], buildEnv());
|
||||
console.log(`テスト用DB削除完了: ${dbName}`);
|
||||
}
|
||||
|
||||
// restore.js が使う汎用版(任意のDB名に対して作成・削除)。
|
||||
// dropDatabase は既定で --if-exists を付けない(復元対象DBが本当に存在するか厳密に確認するため)。
|
||||
async function createDatabase(dbName) {
|
||||
const bin = process.env.PG_CREATEDB_BIN || "createdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
await runCommand(bin, ["-h", host, "-p", String(port), "-U", user, dbName], buildEnv());
|
||||
}
|
||||
|
||||
async function dropDatabase(dbName, { ifExists = false } = {}) {
|
||||
const bin = process.env.PG_DROPDB_BIN || "dropdb";
|
||||
const { host, port, user } = connectionInfo();
|
||||
|
||||
const args = ["-h", host, "-p", String(port), "-U", user];
|
||||
if (ifExists) args.push("--if-exists");
|
||||
args.push(dbName);
|
||||
|
||||
await runCommand(bin, args, buildEnv());
|
||||
}
|
||||
|
||||
module.exports = { createTestDb, dropTestDb, createDatabase, dropDatabase };
|
||||
94
dbbackup/lineworks-authorize.js
Normal file
94
dbbackup/lineworks-authorize.js
Normal file
@ -0,0 +1,94 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* User Account認証(OAuth 2.0 認可コードフロー)の一回限りのセットアップスクリプト。
|
||||
* Drive/共有ドライブAPIはService Account認証では利用できないため、実ユーザーの
|
||||
* 認可を一度だけ行い、refresh_tokenを.envに保存する。以降のbackup.js実行は
|
||||
* このrefresh_tokenからaccess_tokenを自動再発行する(ユーザー操作不要)。
|
||||
*
|
||||
* 事前準備:
|
||||
* Developer Consoleの「User Account認証」欄の Redirect URL に、
|
||||
* .env の LW_REDIRECT_URI と完全一致する値を登録しておくこと。
|
||||
*
|
||||
* 使い方:
|
||||
* node lineworks-authorize.js
|
||||
*/
|
||||
|
||||
const crypto = require("crypto");
|
||||
const readline = require("readline");
|
||||
|
||||
const { loadEnv, updateEnvValue } = require("./lib/env");
|
||||
loadEnv();
|
||||
|
||||
const { buildAuthorizeUrl, exchangeCodeForToken } = require("./lib/lineworksUserAuth");
|
||||
|
||||
function prompt(question) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// リダイレクト後のURL全体、または code の値そのものが貼り付けられるケースの両方に対応する。
|
||||
function extractCode(input) {
|
||||
if (input.includes("code=")) {
|
||||
try {
|
||||
const url = new URL(input);
|
||||
const code = url.searchParams.get("code");
|
||||
if (code) return code;
|
||||
} catch (_err) {
|
||||
const match = input.match(/[?&]code=([^&\s]+)/);
|
||||
if (match) return decodeURIComponent(match[1]);
|
||||
}
|
||||
}
|
||||
return input;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const state = crypto.randomBytes(16).toString("hex");
|
||||
const authorizeUrl = buildAuthorizeUrl({ state });
|
||||
|
||||
console.log("以下のURLをブラウザで開き、対象のLINE WORKSアカウントでログイン・同意してください:");
|
||||
console.log("");
|
||||
console.log(authorizeUrl);
|
||||
console.log("");
|
||||
console.log("同意後、Redirect URLへ遷移します(ページ自体が表示されなくても問題ありません)。");
|
||||
console.log("遷移後のブラウザのアドレスバーのURL全体(または code= の値だけ)をコピーしてください。");
|
||||
console.log("");
|
||||
|
||||
const input = await prompt("リダイレクト後のURL(またはcode)を貼り付けてEnter: ");
|
||||
const code = extractCode(input);
|
||||
|
||||
if (!code) {
|
||||
console.error("codeを取得できませんでした。");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
console.log("アクセストークンに交換しています...");
|
||||
const tokenData = await exchangeCodeForToken(code);
|
||||
|
||||
if (!tokenData.refresh_token) {
|
||||
console.error("レスポンスに refresh_token が含まれていませんでした。");
|
||||
process.exitCode = 1;
|
||||
return;
|
||||
}
|
||||
|
||||
updateEnvValue("LW_USER_REFRESH_TOKEN", tokenData.refresh_token);
|
||||
|
||||
console.log("");
|
||||
console.log("成功しました。");
|
||||
console.log(` scope: ${tokenData.scope}`);
|
||||
console.log(` access_tokenの有効期限: ${tokenData.expires_in}秒`);
|
||||
console.log(" refresh_token を .env の LW_USER_REFRESH_TOKEN に保存しました。");
|
||||
console.log("");
|
||||
console.log("これで 'node backup.js run' が共有ドライブへアクセスできるようになります。");
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(`セットアップに失敗しました: ${err.message}`);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
17
dbbackup/package.json
Normal file
17
dbbackup/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "dbbackup",
|
||||
"version": "1.0.0",
|
||||
"private": true,
|
||||
"description": "PostgreSQL(Pleasanter)のバックアップをLINE WORKS共有ドライブへ自動アップロードする",
|
||||
"main": "backup.js",
|
||||
"scripts": {
|
||||
"backup": "node backup.js run",
|
||||
"create-test-db": "node backup.js create-test-db",
|
||||
"drop-test-db": "node backup.js drop-test-db",
|
||||
"restore": "node restore.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.13.0"
|
||||
},
|
||||
"license": "UNLICENSED"
|
||||
}
|
||||
450
dbbackup/restore.js
Normal file
450
dbbackup/restore.js
Normal file
@ -0,0 +1,450 @@
|
||||
"use strict";
|
||||
|
||||
/*
|
||||
* リストア
|
||||
* LINE WORKS共有ドライブから、このサーバー(ホスト名)の最新バックアップを取得し復元する。
|
||||
* DBリストアと設定ファイルリストアは別コマンド(サブコマンド)として独立している。
|
||||
*
|
||||
* 使い方:
|
||||
* node restore.js [database] [--database=<dbName>] [--yes] … DBリストア(省略時のデフォルト)
|
||||
* node restore.js config [--yes] … 設定ファイルリストア
|
||||
*
|
||||
* --database (databaseサブコマンドのみ)復元先DB名を一時的に上書き(省略時は.envのPGDATABASE)
|
||||
* --yes 確認プロンプトをスキップする(自動化・検証用途。通常は付けずに対話確認すること)
|
||||
*
|
||||
* --- database サブコマンドの処理順序(復元前バックアップが復元対象に混ざらないよう、この順序を厳守する) ---
|
||||
* 1. 復元対象(このホスト名の最新バックアップ)を共有ドライブ上で特定・ダウンロード
|
||||
* 2. Webサービス(既定 pleasanter, .envのWEB_SERVICE_NAMEで変更可)を停止(DBへの接続を切る)
|
||||
* 3. 現在のデータベースを安全のため別名でバックアップ(pre-restore-, 通常のpostgres-とは別名のため
|
||||
* 次回以降の「最新バックアップ検索」には混ざらない)
|
||||
* 4. データベースをドロップ→再作成
|
||||
* 5. ダウンロード済みのバックアップ(1.)からpg_restore
|
||||
* 6. Webサービスを再起動(成功・失敗いずれの場合も、2.で実際に停止していた場合のみ)
|
||||
*
|
||||
* --- config サブコマンドの処理順序 ---
|
||||
* (Pleasanter自体のインストール済みであることが前提。dotnet本体やパッケージ一式は対象外)
|
||||
* 1. 最新の設定アーカイブ([hostname]config-<timestamp>.tar.gz)を共有ドライブ上で特定・ダウンロード
|
||||
* 2. 関連サービス(.envのCONFIG_RESTORE_SERVICES、既定 nginx/postgresql-18/pleasanter)を事前に全停止
|
||||
* 3. CONFIG_BACKUP_PATHSの各パスについて、既存のファイル/ディレクトリがあれば
|
||||
* "<path>.pre-restore-<timestamp>" に必ずリネームして保持してから、アーカイブの内容を配置
|
||||
* 4. サービスの自動再起動は行わない。手動で実行すべきsystemctlコマンドをログ・画面に表示するのみ
|
||||
*/
|
||||
|
||||
require("./lib/env").loadEnv();
|
||||
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
const readline = require("readline");
|
||||
|
||||
const logger = require("./lib/logger");
|
||||
const { getUserAccessToken } = require("./lib/lineworksUserAuth");
|
||||
const drive = require("./lib/lineworksDrive");
|
||||
const { findLatestBackupFileSet, findLatestConfigArchive } = require("./lib/backupLocator");
|
||||
const { runPgDump, formatTimestamp, formatDateFolderName } = require("./lib/pgDump");
|
||||
const { runPgRestore } = require("./lib/pgRestore");
|
||||
const { createDatabase, dropDatabase } = require("./lib/testDb");
|
||||
const { uploadLogFile } = require("./lib/logUpload");
|
||||
const { isServiceActive, stopService, startService } = require("./lib/serviceControl");
|
||||
const { parseConfigPaths } = require("./lib/configBackup");
|
||||
const { extractArchive, restoreConfigPaths } = require("./lib/configRestore");
|
||||
|
||||
function prompt(question) {
|
||||
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
||||
return new Promise((resolve) => {
|
||||
rl.question(question, (answer) => {
|
||||
rl.close();
|
||||
resolve(answer.trim());
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const rest = argv.slice(2);
|
||||
const rawCommand = rest[0];
|
||||
const hasExplicitCommand = rawCommand && !rawCommand.startsWith("--");
|
||||
const command = hasExplicitCommand ? rawCommand : "database";
|
||||
const remaining = hasExplicitCommand ? rest.slice(1) : rest;
|
||||
|
||||
const options = {};
|
||||
for (const arg of remaining) {
|
||||
const m = /^--([^=]+)=(.*)$/.exec(arg);
|
||||
if (m) {
|
||||
options[m[1]] = m[2];
|
||||
} else if (arg.startsWith("--")) {
|
||||
options[arg.slice(2)] = true;
|
||||
}
|
||||
}
|
||||
return { command, options };
|
||||
}
|
||||
|
||||
async function restoreDatabase(options) {
|
||||
const startedAt = Date.now();
|
||||
|
||||
const pgDumpBin = process.env.PG_DUMP_BIN || "pg_dump";
|
||||
const pgRestoreBin = process.env.PG_RESTORE_BIN || "pg_restore";
|
||||
const host = process.env.PGHOST || "localhost";
|
||||
const port = process.env.PGPORT || "5432";
|
||||
const user = process.env.PGUSER || "postgres";
|
||||
const password = process.env.PGPASSWORD || "";
|
||||
const targetDatabase = options.database || process.env.PGDATABASE || "Implem.Pleasanter";
|
||||
const serviceName = process.env.WEB_SERVICE_NAME || "pleasanter";
|
||||
const hostname = os.hostname();
|
||||
const localTmpDir = process.env.BACKUP_LOCAL_DIR
|
||||
? path.resolve(process.env.BACKUP_LOCAL_DIR)
|
||||
: path.join(__dirname, "dump_tmp");
|
||||
|
||||
logger.info("===== リストア開始 =====");
|
||||
logger.info(`対象データベース=${targetDatabase} ホスト名=${hostname}`);
|
||||
|
||||
let accessToken = null;
|
||||
let sharedriveId = null;
|
||||
let backupFolderId = null;
|
||||
let restoreTmpDir = null;
|
||||
let serviceWasStopped = false;
|
||||
|
||||
try {
|
||||
logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)");
|
||||
accessToken = await getUserAccessToken();
|
||||
logger.info("アクセストークン取得成功");
|
||||
|
||||
sharedriveId = await drive.resolveSharedDriveId(accessToken, logger);
|
||||
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(accessToken, sharedriveId, null, rootFolderName, logger);
|
||||
backupFolderId = backupFolder.fileId;
|
||||
|
||||
// 1. 復元対象を特定
|
||||
logger.info(`最新バックアップ検索開始: hostname=${hostname}`);
|
||||
const backupSet = await findLatestBackupFileSet(
|
||||
accessToken,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
hostname,
|
||||
drive,
|
||||
logger
|
||||
);
|
||||
if (!backupSet) {
|
||||
throw new Error(`hostname=${hostname} のバックアップが共有ドライブ上に見つかりませんでした`);
|
||||
}
|
||||
logger.info(
|
||||
`最新バックアップ特定: 日付フォルダ=${backupSet.dateFolderName} timestamp=${backupSet.timestamp} ` +
|
||||
`ファイル数=${backupSet.files.length}`
|
||||
);
|
||||
|
||||
console.log("");
|
||||
console.log("以下の内容でリストアを実行します:");
|
||||
console.log(
|
||||
` 復元元: ${rootFolderName}/${backupSet.dateFolderName}/ ` +
|
||||
`(timestamp=${backupSet.timestamp}, ${backupSet.files.length}ファイル, hostname=${hostname})`
|
||||
);
|
||||
console.log(` 復元先データベース: ${targetDatabase} (host=${host} port=${port} user=${user})`);
|
||||
console.log(` 復元前に現在の「${targetDatabase}」は安全のため別途バックアップした上でドロップされます。`);
|
||||
if (options.database) {
|
||||
console.log(` (--database指定のため「${serviceName}」サービスの停止・再起動は行いません)`);
|
||||
} else {
|
||||
console.log(` 復元中は「${serviceName}」サービスを停止します(完了後に自動で再起動します)。`);
|
||||
}
|
||||
console.log(" この操作は取り消せません。");
|
||||
console.log("");
|
||||
|
||||
if (!options.yes) {
|
||||
const answer = await prompt(`本当に実行しますか?続行するには "${targetDatabase}" と入力してEnter: `);
|
||||
if (answer !== targetDatabase) {
|
||||
logger.info("ユーザーによりキャンセルされました(入力不一致)。");
|
||||
console.log("入力が一致しなかったため中断しました。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. (続き) ダウンロード・再結合
|
||||
fs.mkdirSync(localTmpDir, { recursive: true });
|
||||
restoreTmpDir = path.join(localTmpDir, `restore-${backupSet.timestamp}`);
|
||||
fs.mkdirSync(restoreTmpDir, { recursive: true });
|
||||
|
||||
const downloadedParts = [];
|
||||
for (const file of backupSet.files) {
|
||||
const destPath = path.join(restoreTmpDir, file.fileName);
|
||||
await drive.downloadFile(accessToken, sharedriveId, file.fileId, destPath, logger);
|
||||
downloadedParts.push(destPath);
|
||||
}
|
||||
|
||||
const reassembledPath = path.join(restoreTmpDir, `restore-${backupSet.timestamp}.dmp`);
|
||||
const writeStream = fs.createWriteStream(reassembledPath);
|
||||
for (const part of downloadedParts) {
|
||||
writeStream.write(fs.readFileSync(part));
|
||||
}
|
||||
await new Promise((resolve, reject) => {
|
||||
writeStream.end((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
logger.info(
|
||||
`バックアップファイル再結合完了: ${reassembledPath} (${fs.statSync(reassembledPath).size}bytes)`
|
||||
);
|
||||
|
||||
// 2. Webサービス停止
|
||||
// --database で本番以外(テスト用DB等)を指定した場合は、本番サービスを巻き込まないよう停止処理自体を行わない。
|
||||
if (options.database) {
|
||||
logger.info(
|
||||
`--database指定によるDB上書き実行のため、Webサービス(${serviceName})の停止・再起動はスキップします。`
|
||||
);
|
||||
} else {
|
||||
const serviceActiveBefore = await isServiceActive(serviceName);
|
||||
if (serviceActiveBefore) {
|
||||
logger.info(`Webサービス停止開始: ${serviceName}`);
|
||||
await stopService(serviceName);
|
||||
serviceWasStopped = true;
|
||||
logger.info(`Webサービス停止完了: ${serviceName}`);
|
||||
} else {
|
||||
logger.warn(`Webサービス「${serviceName}」は既に停止していたため、停止処理はスキップしました(完了後の自動再起動も行いません)。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 現在のDBを安全のため別途バックアップ
|
||||
logger.info(`復元前の安全バックアップ開始: database=${targetDatabase}`);
|
||||
const safetyTimestamp = formatTimestamp();
|
||||
const { files: safetyDumpFiles } = await runPgDump({
|
||||
pgDumpBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database: targetDatabase,
|
||||
outputDir: localTmpDir,
|
||||
splitSizeMb: Number(process.env.PG_DUMP_SPLIT_SIZE_MB || 3072),
|
||||
timestamp: safetyTimestamp,
|
||||
prefix: `[${hostname}]pre-restore-`,
|
||||
});
|
||||
logger.info(`復元前の安全バックアップ完了(pg_dump): ファイル数=${safetyDumpFiles.length}`);
|
||||
|
||||
const safetyDateFolder = await drive.ensureFolder(
|
||||
accessToken,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
formatDateFolderName(),
|
||||
logger
|
||||
);
|
||||
for (const filePath of safetyDumpFiles) {
|
||||
await drive.uploadFile(accessToken, sharedriveId, safetyDateFolder.fileId, filePath, { overwrite: false }, logger);
|
||||
}
|
||||
for (const filePath of safetyDumpFiles) {
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
logger.info("復元前の安全バックアップのアップロード完了・ローカル一時ファイル削除完了");
|
||||
|
||||
// 4. ドロップ→再作成
|
||||
logger.info(`データベースドロップ開始: ${targetDatabase}`);
|
||||
await dropDatabase(targetDatabase, { ifExists: false });
|
||||
logger.info(`データベースドロップ完了: ${targetDatabase}`);
|
||||
|
||||
logger.info(`データベース再作成開始: ${targetDatabase}`);
|
||||
await createDatabase(targetDatabase);
|
||||
logger.info(`データベース再作成完了: ${targetDatabase}`);
|
||||
|
||||
// 5. pg_restore
|
||||
logger.info(`pg_restore開始: ${reassembledPath} → ${targetDatabase}`);
|
||||
const restoreStartedAt = Date.now();
|
||||
const { stderrOutput } = await runPgRestore({
|
||||
pgRestoreBin,
|
||||
host,
|
||||
port,
|
||||
user,
|
||||
password,
|
||||
database: targetDatabase,
|
||||
dumpFilePath: reassembledPath,
|
||||
});
|
||||
const restoreElapsedSec = ((Date.now() - restoreStartedAt) / 1000).toFixed(1);
|
||||
if (stderrOutput) {
|
||||
logger.warn(`pg_restore警告出力: ${stderrOutput}`);
|
||||
}
|
||||
logger.info(`pg_restore終了: 所要時間=${restoreElapsedSec}秒`);
|
||||
|
||||
fs.rmSync(restoreTmpDir, { recursive: true, force: true });
|
||||
logger.info("ダウンロード分のローカル一時ファイル削除完了");
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`===== リストア終了(成功) ===== 所要時間=${elapsedSec}秒`);
|
||||
} catch (err) {
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.error(`===== リストア終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`);
|
||||
if (restoreTmpDir) {
|
||||
fs.rmSync(restoreTmpDir, { recursive: true, force: true });
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (serviceWasStopped) {
|
||||
try {
|
||||
logger.info(`Webサービス再起動開始: ${serviceName}`);
|
||||
await startService(serviceName);
|
||||
logger.info(`Webサービス再起動完了: ${serviceName}`);
|
||||
} catch (err) {
|
||||
logger.error(
|
||||
`Webサービス再起動失敗: ${serviceName} 理由=${err.message} ` +
|
||||
`(手動で 'systemctl start ${serviceName}' を実行してください)`
|
||||
);
|
||||
}
|
||||
}
|
||||
await uploadLogFile({ accessToken, sharedriveId, backupFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
async function restoreConfig(options) {
|
||||
const startedAt = Date.now();
|
||||
const hostname = os.hostname();
|
||||
const targetPaths = parseConfigPaths();
|
||||
const serviceNames = (process.env.CONFIG_RESTORE_SERVICES || "nginx,postgresql-18,pleasanter")
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean);
|
||||
const localTmpDir = process.env.BACKUP_LOCAL_DIR
|
||||
? path.resolve(process.env.BACKUP_LOCAL_DIR)
|
||||
: path.join(__dirname, "dump_tmp");
|
||||
|
||||
logger.info("===== 設定ファイルリストア開始 =====");
|
||||
logger.info(`ホスト名=${hostname} 対象パス数=${targetPaths.length} 対象サービス=${serviceNames.join(",")}`);
|
||||
|
||||
let accessToken = null;
|
||||
let sharedriveId = null;
|
||||
let backupFolderId = null;
|
||||
let extractDir = null;
|
||||
const stoppedServices = [];
|
||||
|
||||
try {
|
||||
if (targetPaths.length === 0) {
|
||||
throw new Error("CONFIG_BACKUP_PATHSが未設定です");
|
||||
}
|
||||
|
||||
logger.info("LINE WORKSアクセストークン取得開始 (User Account認証)");
|
||||
accessToken = await getUserAccessToken();
|
||||
logger.info("アクセストークン取得成功");
|
||||
|
||||
sharedriveId = await drive.resolveSharedDriveId(accessToken, logger);
|
||||
|
||||
const rootFolderName = process.env.LW_BACKUP_ROOT_FOLDER_NAME || "Pleasanter_DB_Backup";
|
||||
const backupFolder = await drive.ensureFolder(accessToken, sharedriveId, null, rootFolderName, logger);
|
||||
backupFolderId = backupFolder.fileId;
|
||||
|
||||
logger.info(`最新設定アーカイブ検索開始: hostname=${hostname}`);
|
||||
const configArchive = await findLatestConfigArchive(
|
||||
accessToken,
|
||||
sharedriveId,
|
||||
backupFolderId,
|
||||
hostname,
|
||||
drive,
|
||||
logger
|
||||
);
|
||||
if (!configArchive) {
|
||||
throw new Error(`hostname=${hostname} の設定アーカイブが共有ドライブ上に見つかりませんでした`);
|
||||
}
|
||||
logger.info(
|
||||
`最新設定アーカイブ特定: 日付フォルダ=${configArchive.dateFolderName} timestamp=${configArchive.timestamp}`
|
||||
);
|
||||
|
||||
console.log("");
|
||||
console.log("以下の内容で設定ファイルリストアを実行します:");
|
||||
console.log(
|
||||
` 復元元: ${rootFolderName}/${configArchive.dateFolderName}/${configArchive.file.fileName}`
|
||||
);
|
||||
console.log(` 復元先パス(${targetPaths.length}件):`);
|
||||
for (const p of targetPaths) {
|
||||
console.log(` - ${p}`);
|
||||
}
|
||||
console.log(" 既存のファイル/ディレクトリは削除せず、必ず \"<path>.pre-restore-<timestamp>\" にリネームして保持します。");
|
||||
console.log(` 復元前に以下のサービスをすべて停止します(完了後の自動再起動は行いません): ${serviceNames.join(", ")}`);
|
||||
console.log(" Pleasanter自体のインストール(dotnet本体・パッケージ一式)は対象外です。");
|
||||
console.log(" この操作は取り消せません。");
|
||||
console.log("");
|
||||
|
||||
if (!options.yes) {
|
||||
const answer = await prompt(`本当に実行しますか?続行するには "${hostname}" と入力してEnter: `);
|
||||
if (answer !== hostname) {
|
||||
logger.info("ユーザーによりキャンセルされました(入力不一致)。");
|
||||
console.log("入力が一致しなかったため中断しました。");
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// 1. ダウンロード
|
||||
fs.mkdirSync(localTmpDir, { recursive: true });
|
||||
const archiveLocalPath = path.join(localTmpDir, configArchive.file.fileName);
|
||||
await drive.downloadFile(accessToken, sharedriveId, configArchive.file.fileId, archiveLocalPath, logger);
|
||||
|
||||
// 2. サービス事前停止(全対象。個別のパスとの対応付けはせず、常に全部止める)
|
||||
for (const serviceName of serviceNames) {
|
||||
const activeBefore = await isServiceActive(serviceName);
|
||||
if (activeBefore) {
|
||||
logger.info(`サービス停止開始: ${serviceName}`);
|
||||
await stopService(serviceName);
|
||||
stoppedServices.push(serviceName);
|
||||
logger.info(`サービス停止完了: ${serviceName}`);
|
||||
} else {
|
||||
logger.warn(`サービス「${serviceName}」は既に停止していたため、停止処理はスキップしました。`);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. 展開→各パスへ「退避してから配置」
|
||||
extractDir = path.join(localTmpDir, `config-restore-${configArchive.timestamp}`);
|
||||
await extractArchive(archiveLocalPath, extractDir);
|
||||
logger.info(`設定アーカイブ展開完了: ${extractDir}`);
|
||||
|
||||
const restoreTimestamp = formatTimestamp();
|
||||
const results = await restoreConfigPaths({
|
||||
extractDir,
|
||||
targetPaths,
|
||||
timestamp: restoreTimestamp,
|
||||
logger,
|
||||
});
|
||||
|
||||
fs.unlinkSync(archiveLocalPath);
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
logger.info("ローカル一時ファイル(アーカイブ・展開分)削除完了");
|
||||
|
||||
const restoredCount = results.filter((r) => r.status === "restored").length;
|
||||
const skippedCount = results.filter((r) => r.status === "skipped").length;
|
||||
logger.info(`設定ファイル配置結果: 復元${restoredCount}件 / スキップ${skippedCount}件`);
|
||||
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.info(`===== 設定ファイルリストア終了(成功) ===== 所要時間=${elapsedSec}秒`);
|
||||
} catch (err) {
|
||||
const elapsedSec = ((Date.now() - startedAt) / 1000).toFixed(1);
|
||||
logger.error(`===== 設定ファイルリストア終了(失敗) ===== 所要時間=${elapsedSec}秒 理由=${err.message}`);
|
||||
if (extractDir) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
}
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
if (stoppedServices.length > 0) {
|
||||
console.log("");
|
||||
console.log("以下のサービスを停止したままにしています。確認の上、手動で起動してください:");
|
||||
for (const serviceName of stoppedServices) {
|
||||
if (serviceName === "nginx") {
|
||||
console.log(` nginx -t && systemctl start nginx`);
|
||||
} else {
|
||||
console.log(` systemctl start ${serviceName}`);
|
||||
}
|
||||
}
|
||||
console.log("");
|
||||
logger.warn(
|
||||
`以下のサービスを停止したままです。手動で起動してください: ${stoppedServices.join(", ")}`
|
||||
);
|
||||
}
|
||||
await uploadLogFile({ accessToken, sharedriveId, backupFolderId });
|
||||
}
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { command, options } = parseArgs(process.argv);
|
||||
|
||||
if (command === "database") {
|
||||
await restoreDatabase(options);
|
||||
} else if (command === "config") {
|
||||
await restoreConfig(options);
|
||||
} else {
|
||||
console.log("使い方: node restore.js [database [--database=<dbName>] | config] [--yes]");
|
||||
process.exitCode = 1;
|
||||
}
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err.message);
|
||||
process.exitCode = 1;
|
||||
});
|
||||
1887
dotnet-install.sh
vendored
Normal file
1887
dotnet-install.sh
vendored
Normal file
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue
Block a user